mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-14 13:55:55 -07:00
fix(stats): preserve lifetime history across anime merges and moves
- Recompute anime aggregates from retained lifetime media summaries - Prevent sequel title evidence and AniList conflicts from misattributing entries
This commit is contained in:
@@ -244,19 +244,18 @@ function queueTelemetry(tracker: TrackerInternals, linesSeen: number): void {
|
||||
});
|
||||
}
|
||||
|
||||
function lifetimeForAnime(
|
||||
db: DatabaseSync,
|
||||
animeId: number,
|
||||
): { linesSeen: number; activeMs: number; cards: number } | null {
|
||||
/** The queued telemetry sample only exists in the database once the queue drained fully. */
|
||||
function latestTelemetryLinesSeen(db: DatabaseSync, sessionId: number): number | null {
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT total_lines_seen AS linesSeen, total_active_ms AS activeMs, total_cards AS cards
|
||||
FROM imm_lifetime_anime WHERE anime_id = ?`,
|
||||
`SELECT lines_seen AS linesSeen
|
||||
FROM imm_session_telemetry
|
||||
WHERE session_id = ?
|
||||
ORDER BY sample_ms DESC, telemetry_id DESC
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get(animeId) as { linesSeen: number; activeMs: number; cards: number } | undefined;
|
||||
return row
|
||||
? { linesSeen: Number(row.linesSeen), activeMs: Number(row.activeMs), cards: Number(row.cards) }
|
||||
: null;
|
||||
.get(sessionId) as { linesSeen: number } | undefined;
|
||||
return row ? Number(row.linesSeen) : null;
|
||||
}
|
||||
|
||||
function countLinesForAnime(db: DatabaseSync, animeId: number): number {
|
||||
@@ -267,12 +266,12 @@ function countLinesForAnime(db: DatabaseSync, animeId: number): number {
|
||||
}
|
||||
|
||||
/**
|
||||
* Both entry points rebuild the lifetime summaries, which recompute from the
|
||||
* database. A single flushNow() only writes one batch off the front of the
|
||||
* queue, so anything past `batchSize` would still be unwritten when the rebuild
|
||||
* reads.
|
||||
* Both entry points must see a settled database before changing episode
|
||||
* ownership. A single flushNow() only writes one batch off the front of the
|
||||
* queue, so anything past `batchSize` would still be unwritten when the merge
|
||||
* repoints rows.
|
||||
*/
|
||||
test('mergeAnime drains a queue larger than one batch before rebuilding summaries', async () => {
|
||||
test('mergeAnime drains a queue larger than one batch before repointing rows', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
|
||||
@@ -289,19 +288,16 @@ test('mergeAnime drains a queue larger than one batch before rebuilding summarie
|
||||
await internals.mergeAnime(1, [2]);
|
||||
|
||||
assert.equal(internals.queue.length, 0);
|
||||
// Every queued line landed, attributed to the surviving entry.
|
||||
assert.equal(countLinesForAnime(internals.db, 1), 8);
|
||||
// The surviving entry's summary was rebuilt from the fully drained queue.
|
||||
const lifetime = lifetimeForAnime(internals.db, 1);
|
||||
assert.equal(lifetime?.linesSeen, 8);
|
||||
assert.equal(lifetime?.activeMs, 3500);
|
||||
assert.equal(lifetime?.cards, 2);
|
||||
assert.equal(latestTelemetryLinesSeen(internals.db, 1), 8);
|
||||
} finally {
|
||||
tracker?.destroy();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('moveVideoToAnime drains a queue larger than one batch before rebuilding summaries', async () => {
|
||||
test('moveVideoToAnime drains a queue larger than one batch before repointing rows', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
|
||||
@@ -318,10 +314,7 @@ test('moveVideoToAnime drains a queue larger than one batch before rebuilding su
|
||||
|
||||
assert.equal(internals.queue.length, 0);
|
||||
assert.equal(countLinesForAnime(internals.db, 1), 8);
|
||||
const lifetime = lifetimeForAnime(internals.db, 1);
|
||||
assert.equal(lifetime?.linesSeen, 8);
|
||||
assert.equal(lifetime?.activeMs, 3500);
|
||||
assert.equal(lifetime?.cards, 2);
|
||||
assert.equal(latestTelemetryLinesSeen(internals.db, 1), 8);
|
||||
} finally {
|
||||
tracker?.destroy();
|
||||
cleanupDbPath(dbPath);
|
||||
|
||||
@@ -156,7 +156,10 @@ test('season 1 resolves to the anchor without relation lookups', async () => {
|
||||
assert.deepEqual(relationLookups, []);
|
||||
});
|
||||
|
||||
test('season 2 preserves the anchor exact-title evidence through a sequel resolution', async () => {
|
||||
test('a sequel resolution is not certified by the anchor exact-title evidence', async () => {
|
||||
// The anchor matched the search title exactly, but the hopped-to entry is a
|
||||
// different inference (a split-cour chain can land one season short), so the
|
||||
// sequel result must report its own title evidence, not the anchor's.
|
||||
const { execute } = createExecutor(OREGAIRU_SEARCH, OREGAIRU_RELATIONS);
|
||||
const result = await resolveAnilistSeasonMedia(
|
||||
{ title: 'My Teen Romantic Comedy SNAFU', season: 2, episode: 1 },
|
||||
@@ -165,6 +168,33 @@ test('season 2 preserves the anchor exact-title evidence through a sequel resolu
|
||||
|
||||
assert.equal(result?.id, 20698);
|
||||
assert.equal(result?.via, 'sequel-chain');
|
||||
assert.equal(result?.exactTitleMatch, false);
|
||||
});
|
||||
|
||||
test('a sequel resolution whose own title matches the parsed title stays exact', async () => {
|
||||
const anchor: AnilistSeasonMedia = {
|
||||
id: 1,
|
||||
episodes: 12,
|
||||
format: 'TV',
|
||||
title: { english: 'Show' },
|
||||
};
|
||||
const sequel: AnilistSeasonMedia = {
|
||||
id: 2,
|
||||
episodes: 12,
|
||||
format: 'TV',
|
||||
title: { english: 'Show 2nd Season' },
|
||||
};
|
||||
const { execute } = createExecutor([anchor], {
|
||||
1: [{ relationType: 'SEQUEL', node: sequel }],
|
||||
});
|
||||
|
||||
const result = await resolveAnilistSeasonMedia(
|
||||
{ title: 'Show 2nd Season', season: 2, episode: 1 },
|
||||
{ execute },
|
||||
);
|
||||
|
||||
assert.equal(result?.id, 2);
|
||||
assert.equal(result?.via, 'sequel-chain');
|
||||
assert.equal(result?.exactTitleMatch, true);
|
||||
});
|
||||
|
||||
|
||||
@@ -370,10 +370,20 @@ export async function resolveAnilistSeasonMedia(
|
||||
episode: season === null || season <= 1 ? input.episode : null,
|
||||
});
|
||||
if (!anchor) return null;
|
||||
const exactTitleMatch = mediaTitles(anchor).includes(normalizeTitleIdentity(searchTitle));
|
||||
// Certifies the media actually returned, never the anchor on its behalf: a
|
||||
// sequel-chain hop can land one season short (split-cour entries) while the
|
||||
// anchor title still matches perfectly, and that certainty must not carry
|
||||
// over to the hopped-to entry.
|
||||
const exactMatchFor = (candidate: AnilistSeasonMedia): boolean => {
|
||||
const titles = mediaTitles(candidate);
|
||||
return (
|
||||
titles.includes(normalizeTitleIdentity(searchTitle)) ||
|
||||
titles.includes(normalizeTitleIdentity(input.title))
|
||||
);
|
||||
};
|
||||
|
||||
if (season === null || season <= 1) {
|
||||
return toResolution(anchor, searchTitle, season, 'anchor', true, exactTitleMatch);
|
||||
return toResolution(anchor, searchTitle, season, 'anchor', true, exactMatchFor(anchor));
|
||||
}
|
||||
|
||||
let chainError: unknown = null;
|
||||
@@ -387,7 +397,7 @@ export async function resolveAnilistSeasonMedia(
|
||||
deps.logInfo?.(
|
||||
`[anilist] season ${season} of "${searchTitle}" resolved via sequel chain: ${displayTitle(viaChain, searchTitle)} (${viaChain.id})`,
|
||||
);
|
||||
return toResolution(viaChain, searchTitle, season, 'sequel-chain', true, exactTitleMatch);
|
||||
return toResolution(viaChain, searchTitle, season, 'sequel-chain', true, exactMatchFor(viaChain));
|
||||
}
|
||||
|
||||
const viaAirOrder = pickByAirOrder(anchor, season, media);
|
||||
@@ -395,7 +405,14 @@ export async function resolveAnilistSeasonMedia(
|
||||
deps.logInfo?.(
|
||||
`[anilist] season ${season} of "${searchTitle}" resolved via air order: ${displayTitle(viaAirOrder, searchTitle)} (${viaAirOrder.id})`,
|
||||
);
|
||||
return toResolution(viaAirOrder, searchTitle, season, 'air-order', true, exactTitleMatch);
|
||||
return toResolution(
|
||||
viaAirOrder,
|
||||
searchTitle,
|
||||
season,
|
||||
'air-order',
|
||||
true,
|
||||
exactMatchFor(viaAirOrder),
|
||||
);
|
||||
}
|
||||
|
||||
// The chain failed for transport reasons rather than because the season is absent;
|
||||
@@ -407,5 +424,5 @@ export async function resolveAnilistSeasonMedia(
|
||||
deps.logInfo?.(
|
||||
`[anilist] could not resolve season ${season} of "${searchTitle}"; falling back to ${displayTitle(anchor, searchTitle)} (${anchor.id})`,
|
||||
);
|
||||
return toResolution(anchor, searchTitle, season, 'anchor', false, exactTitleMatch);
|
||||
return toResolution(anchor, searchTitle, season, 'anchor', false, exactMatchFor(anchor));
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from './immersion-tracker/storage';
|
||||
import {
|
||||
applySessionLifetimeSummary,
|
||||
recomputeLifetimeAnimeAggregates,
|
||||
reconcileStaleActiveSessions,
|
||||
rebuildLifetimeSummaries as rebuildLifetimeSummaryTables,
|
||||
shouldBackfillLifetimeSummaries,
|
||||
@@ -526,7 +527,7 @@ export class ImmersionTrackerService {
|
||||
this.logger.info(
|
||||
`Repaired season-scoped stats links on startup: scanned=${seasonRepair.scanned} movedVideos=${seasonRepair.movedVideos} deletedAnimeRows=${seasonRepair.deletedAnimeRows}`,
|
||||
);
|
||||
rebuildLifetimeSummaryTables(this.db);
|
||||
recomputeLifetimeAnimeAggregates(this.db);
|
||||
}
|
||||
if (shouldBackfillLifetimeSummaries(this.db)) {
|
||||
const result = rebuildLifetimeSummaryTables(this.db);
|
||||
@@ -924,7 +925,7 @@ export class ImmersionTrackerService {
|
||||
animeId,
|
||||
);
|
||||
if (repair.movedVideos > 0 || repair.deletedAnimeRows > 0) {
|
||||
rebuildLifetimeSummaryTables(this.db);
|
||||
recomputeLifetimeAnimeAggregates(this.db);
|
||||
}
|
||||
|
||||
// Update cover art for all videos in this anime
|
||||
@@ -1372,7 +1373,7 @@ export class ImmersionTrackerService {
|
||||
metadataJson: candidate.metadataJson,
|
||||
});
|
||||
}
|
||||
rebuildLifetimeSummaryTables(this.db);
|
||||
recomputeLifetimeAnimeAggregates(this.db);
|
||||
}
|
||||
|
||||
recordJellyfinPlaybackMetadata(metadata: JellyfinPlaybackMetadataInput): void {
|
||||
@@ -1445,7 +1446,7 @@ export class ImmersionTrackerService {
|
||||
this.db.prepare('SELECT 1 FROM imm_lifetime_media WHERE video_id = ?').get(videoId),
|
||||
);
|
||||
if (hasLifetimeMedia || (previousLink && previousLink.animeId !== animeId)) {
|
||||
rebuildLifetimeSummaryTables(this.db);
|
||||
recomputeLifetimeAnimeAggregates(this.db);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -72,7 +72,10 @@ interface EpisodeSeed {
|
||||
cards?: number;
|
||||
}
|
||||
|
||||
/** One episode with one ended session, so lifetime rebuilds have something to sum. */
|
||||
/**
|
||||
* One episode with one ended session, plus the imm_lifetime_media row the
|
||||
* session would have left behind, so lifetime aggregates have something to sum.
|
||||
*/
|
||||
function insertEpisode(db: DatabaseSync, seed: EpisodeSeed): void {
|
||||
const activeMs = seed.activeMs ?? 1000;
|
||||
const cards = seed.cards ?? 1;
|
||||
@@ -107,6 +110,18 @@ function insertEpisode(db: DatabaseSync, seed: EpisodeSeed): void {
|
||||
`INSERT INTO imm_subtitle_lines(session_id, video_id, anime_id, line_index, text, CREATED_DATE, LAST_UPDATE_DATE)
|
||||
VALUES (?, ?, ?, 1, ?, ?, ?)`,
|
||||
).run(seed.videoId, seed.videoId, seed.animeId, `line ${seed.videoId}`, BASE_MS, BASE_MS);
|
||||
db.prepare(
|
||||
`INSERT INTO imm_lifetime_media(video_id, total_sessions, total_active_ms, total_cards, completed, first_watched_ms, last_watched_ms, CREATED_DATE, LAST_UPDATE_DATE)
|
||||
VALUES (?, 1, ?, ?, 1, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
seed.videoId,
|
||||
activeMs,
|
||||
cards,
|
||||
String(BASE_MS),
|
||||
String(BASE_MS + activeMs),
|
||||
BASE_MS,
|
||||
BASE_MS,
|
||||
);
|
||||
}
|
||||
|
||||
function animeIds(db: DatabaseSync): number[] {
|
||||
@@ -162,6 +177,49 @@ test('mergeAnimeRecords folds episodes, lines and lifetime totals into the targe
|
||||
});
|
||||
});
|
||||
|
||||
test('merge and move preserve lifetime history whose raw sessions were pruned', () => {
|
||||
withDb((db) => {
|
||||
insertAnime(db, { animeId: 1, key: 'show', title: 'Show' });
|
||||
insertAnime(db, { animeId: 2, key: 'show season 1', title: 'Show Season 1' });
|
||||
insertAnime(db, { animeId: 3, key: 'other show', title: 'Other Show' });
|
||||
insertEpisode(db, { videoId: 1, animeId: 1, activeMs: 1000, cards: 1 });
|
||||
insertEpisode(db, { videoId: 2, animeId: 2, season: 1, activeMs: 2000, cards: 3 });
|
||||
insertEpisode(db, { videoId: 3, animeId: 3, activeMs: 4000, cards: 5 });
|
||||
// Retention pruned every raw session; only the lifetime summaries remain.
|
||||
db.exec('DELETE FROM imm_sessions');
|
||||
db.prepare(
|
||||
`UPDATE imm_lifetime_global
|
||||
SET total_sessions = 200, total_active_ms = 360000000, total_cards = 500, active_days = 90
|
||||
WHERE global_id = 1`,
|
||||
).run();
|
||||
|
||||
mergeAnimeRecords(db, 1, [2]);
|
||||
moveVideoToAnime(db, 3, 1);
|
||||
|
||||
const globalRow = db
|
||||
.prepare(
|
||||
`SELECT total_sessions AS sessions, total_active_ms AS activeMs, total_cards AS cards, active_days AS days
|
||||
FROM imm_lifetime_global WHERE global_id = 1`,
|
||||
)
|
||||
.get() as { sessions: number; activeMs: number; cards: number; days: number };
|
||||
assert.equal(globalRow.sessions, 200);
|
||||
assert.equal(globalRow.activeMs, 360000000);
|
||||
assert.equal(globalRow.cards, 500);
|
||||
assert.equal(globalRow.days, 90);
|
||||
|
||||
const survivor = db
|
||||
.prepare(
|
||||
`SELECT total_active_ms AS activeMs, total_cards AS cards, episodes_started AS episodes
|
||||
FROM imm_lifetime_anime WHERE anime_id = 1`,
|
||||
)
|
||||
.get() as { activeMs: number; cards: number; episodes: number };
|
||||
assert.equal(survivor.activeMs, 7000);
|
||||
assert.equal(survivor.cards, 9);
|
||||
assert.equal(survivor.episodes, 3);
|
||||
assert.equal(db.prepare('SELECT 1 FROM imm_lifetime_anime WHERE anime_id = 3').get(), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
test('mergeAnimeRecords repoints subtitle lines recorded before the anime link landed', () => {
|
||||
withDb((db) => {
|
||||
insertAnime(db, { animeId: 1, key: 'show', title: 'Show' });
|
||||
@@ -698,3 +756,33 @@ test('resolveAnimeAnilistConflict leaves an entry that already links elsewhere a
|
||||
assert.deepEqual(getAnimeMergeRecommendations(db), []);
|
||||
});
|
||||
});
|
||||
|
||||
test('automatic AniList update onto an entry that already links elsewhere does not throw', () => {
|
||||
withDb((db) => {
|
||||
insertAnime(db, { animeId: 1, key: 'show', title: 'Show', anilistId: 163132 });
|
||||
insertAnime(db, { animeId: 2, key: 'show s2', title: 'Show Season 2', anilistId: 999 });
|
||||
insertEpisode(db, { videoId: 1, animeId: 1, season: 1 });
|
||||
insertEpisode(db, { videoId: 2, animeId: 2, season: 2 });
|
||||
|
||||
// Entry 2 explicitly links to 999; a later video re-resolving to entry 1's
|
||||
// id must be refused, not written over the UNIQUE anilist_id column.
|
||||
updateAnimeAnilistInfo(db, 2, {
|
||||
anilistId: 163132,
|
||||
titleRomaji: 'Show',
|
||||
titleEnglish: null,
|
||||
titleNative: null,
|
||||
episodesTotal: 12,
|
||||
exactTitleMatch: true,
|
||||
});
|
||||
|
||||
assert.deepEqual(animeIds(db), [1, 2]);
|
||||
assert.equal(
|
||||
(
|
||||
db.prepare('SELECT anilist_id AS anilistId FROM imm_anime WHERE anime_id = 2').get() as {
|
||||
anilistId: number;
|
||||
}
|
||||
).anilistId,
|
||||
999,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { DatabaseSync } from './sqlite';
|
||||
import { rebuildLifetimeSummariesInTransaction } from './lifetime';
|
||||
import { recomputeLifetimeAnimeAggregatesInTransaction } from './lifetime';
|
||||
import { toDbTimestamp } from './query-shared';
|
||||
import { nowMs } from './time';
|
||||
|
||||
@@ -151,8 +151,9 @@ function absorbAnimeMetadata(
|
||||
* is repointed, metadata the target is missing is inherited from the sources,
|
||||
* and the emptied source rows are deleted.
|
||||
*
|
||||
* Assumes the caller already holds a write transaction and rebuilds the
|
||||
* lifetime summaries afterwards; use {@link mergeAnimeRecords} otherwise.
|
||||
* Assumes the caller already holds a write transaction and refreshes the
|
||||
* per-anime lifetime aggregates afterwards; use {@link mergeAnimeRecords}
|
||||
* otherwise.
|
||||
*/
|
||||
export function mergeAnimeRecordsInTransaction(
|
||||
db: DatabaseSync,
|
||||
@@ -237,7 +238,7 @@ export function mergeAnimeRecords(
|
||||
return runInTransaction(db, () => {
|
||||
const summary = mergeAnimeRecordsInTransaction(db, targetAnimeId, sourceAnimeIds);
|
||||
if (summary.mergedAnimeIds.length > 0) {
|
||||
rebuildLifetimeSummariesInTransaction(db);
|
||||
recomputeLifetimeAnimeAggregatesInTransaction(db);
|
||||
}
|
||||
return summary;
|
||||
});
|
||||
@@ -286,7 +287,7 @@ export function moveVideoToAnime(
|
||||
removedPreviousAnime = true;
|
||||
}
|
||||
|
||||
rebuildLifetimeSummariesInTransaction(db);
|
||||
recomputeLifetimeAnimeAggregatesInTransaction(db);
|
||||
return { targetAnimeId, previousAnimeId, removedPreviousAnime };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -380,8 +380,12 @@ export function resolveAnimeAnilistConflict(
|
||||
targetRow.anilist_id !== anilistId
|
||||
) {
|
||||
// An automatic lookup disagreeing with an existing explicit link is a
|
||||
// mis-resolution, not evidence that either row should move or merge.
|
||||
return emptySummary(1);
|
||||
// mis-resolution, not evidence that either row should move or merge. The
|
||||
// colliding id must not be assigned either: another row owns it and
|
||||
// imm_anime.anilist_id is UNIQUE.
|
||||
const summary = emptySummary(1);
|
||||
summary.anilistAssignmentBlocked = true;
|
||||
return summary;
|
||||
}
|
||||
const isManual = options.survivor === 'target' || options.matchConfidence === 'manual';
|
||||
if (!isManual && hasDismissedAnimeMergeRecommendation(db, targetAnimeId, conflict.animeId)) {
|
||||
|
||||
@@ -708,6 +708,87 @@ export function rebuildLifetimeSummariesInTransaction(
|
||||
return rebuildLifetimeSummariesInternal(db, rebuiltAtMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-derive every per-anime lifetime row from the per-video summaries after
|
||||
* episodes changed owners (merge, move, season repair).
|
||||
*
|
||||
* Deliberately NOT a full rebuild: {@link rebuildLifetimeSummariesInTransaction}
|
||||
* recomputes from raw sessions, which are pruned after the retention window, so
|
||||
* it silently truncates lifetime history. `imm_lifetime_media` is keyed by
|
||||
* video and survives repointing, so aggregating it preserves all-time totals;
|
||||
* `imm_lifetime_global` only needs `anime_completed` refreshed because moving
|
||||
* attribution between entries cannot change the global counters.
|
||||
*
|
||||
* Assumes the caller holds a write transaction; use
|
||||
* {@link recomputeLifetimeAnimeAggregates} otherwise.
|
||||
*/
|
||||
export function recomputeLifetimeAnimeAggregatesInTransaction(db: DatabaseSync): void {
|
||||
const updatedAt = toDbTimestamp(nowMs());
|
||||
db.exec('DELETE FROM imm_lifetime_anime');
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO imm_lifetime_anime (
|
||||
anime_id,
|
||||
total_sessions,
|
||||
total_active_ms,
|
||||
total_cards,
|
||||
total_lines_seen,
|
||||
total_tokens_seen,
|
||||
episodes_started,
|
||||
episodes_completed,
|
||||
first_watched_ms,
|
||||
last_watched_ms,
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE
|
||||
)
|
||||
SELECT
|
||||
v.anime_id,
|
||||
COALESCE(SUM(m.total_sessions), 0),
|
||||
COALESCE(SUM(m.total_active_ms), 0),
|
||||
COALESCE(SUM(m.total_cards), 0),
|
||||
COALESCE(SUM(m.total_lines_seen), 0),
|
||||
COALESCE(SUM(m.total_tokens_seen), 0),
|
||||
COUNT(*),
|
||||
COUNT(CASE WHEN m.completed > 0 THEN 1 END),
|
||||
MIN(m.first_watched_ms),
|
||||
MAX(m.last_watched_ms),
|
||||
?,
|
||||
?
|
||||
FROM imm_lifetime_media m
|
||||
JOIN imm_videos v ON v.video_id = m.video_id
|
||||
WHERE v.anime_id IS NOT NULL
|
||||
GROUP BY v.anime_id
|
||||
`,
|
||||
).run(updatedAt, updatedAt);
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE imm_lifetime_global
|
||||
SET
|
||||
anime_completed = (
|
||||
SELECT COUNT(*)
|
||||
FROM imm_lifetime_anime la
|
||||
JOIN imm_anime a ON a.anime_id = la.anime_id
|
||||
WHERE a.episodes_total IS NOT NULL
|
||||
AND a.episodes_total > 0
|
||||
AND la.episodes_completed >= a.episodes_total
|
||||
),
|
||||
LAST_UPDATE_DATE = ?
|
||||
WHERE global_id = 1
|
||||
`,
|
||||
).run(updatedAt);
|
||||
}
|
||||
|
||||
export function recomputeLifetimeAnimeAggregates(db: DatabaseSync): void {
|
||||
db.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
recomputeLifetimeAnimeAggregatesInTransaction(db);
|
||||
db.exec('COMMIT');
|
||||
} catch (error) {
|
||||
db.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function reconcileStaleActiveSessions(db: DatabaseSync): number {
|
||||
const sessions = getRetainedStaleActiveSessions(db);
|
||||
if (sessions.length === 0) {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { DatabaseSync } from './sqlite';
|
||||
import { buildCoverBlobReference, normalizeCoverBlobBytes } from './storage';
|
||||
import { rebuildLifetimeSummaries, rebuildLifetimeSummariesInTransaction } from './lifetime';
|
||||
import {
|
||||
recomputeLifetimeAnimeAggregates,
|
||||
rebuildLifetimeSummariesInTransaction,
|
||||
} from './lifetime';
|
||||
import { getRollupGroupsForSessions, refreshRollupsForGroupsInTransaction } from './maintenance';
|
||||
import { nowMs } from './time';
|
||||
import { resolveAnimeAnilistConflict } from './anime-season-repair';
|
||||
@@ -460,7 +463,7 @@ export function updateAnimeAnilistInfo(
|
||||
targetRow.anime_id,
|
||||
);
|
||||
if (repair.movedVideos > 0 || repair.deletedAnimeRows > 0) {
|
||||
rebuildLifetimeSummaries(db);
|
||||
recomputeLifetimeAnimeAggregates(db);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user