mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-15 01:55:52 -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(
|
/** The queued telemetry sample only exists in the database once the queue drained fully. */
|
||||||
db: DatabaseSync,
|
function latestTelemetryLinesSeen(db: DatabaseSync, sessionId: number): number | null {
|
||||||
animeId: number,
|
|
||||||
): { linesSeen: number; activeMs: number; cards: number } | null {
|
|
||||||
const row = db
|
const row = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT total_lines_seen AS linesSeen, total_active_ms AS activeMs, total_cards AS cards
|
`SELECT lines_seen AS linesSeen
|
||||||
FROM imm_lifetime_anime WHERE anime_id = ?`,
|
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;
|
.get(sessionId) as { linesSeen: number } | undefined;
|
||||||
return row
|
return row ? Number(row.linesSeen) : null;
|
||||||
? { linesSeen: Number(row.linesSeen), activeMs: Number(row.activeMs), cards: Number(row.cards) }
|
|
||||||
: null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function countLinesForAnime(db: DatabaseSync, animeId: number): number {
|
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
|
* Both entry points must see a settled database before changing episode
|
||||||
* database. A single flushNow() only writes one batch off the front of the
|
* ownership. A single flushNow() only writes one batch off the front of the
|
||||||
* queue, so anything past `batchSize` would still be unwritten when the rebuild
|
* queue, so anything past `batchSize` would still be unwritten when the merge
|
||||||
* reads.
|
* 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();
|
const dbPath = makeDbPath();
|
||||||
let tracker: ImmersionTrackerService | null = null;
|
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]);
|
await internals.mergeAnime(1, [2]);
|
||||||
|
|
||||||
assert.equal(internals.queue.length, 0);
|
assert.equal(internals.queue.length, 0);
|
||||||
|
// Every queued line landed, attributed to the surviving entry.
|
||||||
assert.equal(countLinesForAnime(internals.db, 1), 8);
|
assert.equal(countLinesForAnime(internals.db, 1), 8);
|
||||||
// The surviving entry's summary was rebuilt from the fully drained queue.
|
assert.equal(latestTelemetryLinesSeen(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);
|
|
||||||
} finally {
|
} finally {
|
||||||
tracker?.destroy();
|
tracker?.destroy();
|
||||||
cleanupDbPath(dbPath);
|
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();
|
const dbPath = makeDbPath();
|
||||||
let tracker: ImmersionTrackerService | null = null;
|
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(internals.queue.length, 0);
|
||||||
assert.equal(countLinesForAnime(internals.db, 1), 8);
|
assert.equal(countLinesForAnime(internals.db, 1), 8);
|
||||||
const lifetime = lifetimeForAnime(internals.db, 1);
|
assert.equal(latestTelemetryLinesSeen(internals.db, 1), 8);
|
||||||
assert.equal(lifetime?.linesSeen, 8);
|
|
||||||
assert.equal(lifetime?.activeMs, 3500);
|
|
||||||
assert.equal(lifetime?.cards, 2);
|
|
||||||
} finally {
|
} finally {
|
||||||
tracker?.destroy();
|
tracker?.destroy();
|
||||||
cleanupDbPath(dbPath);
|
cleanupDbPath(dbPath);
|
||||||
|
|||||||
@@ -156,7 +156,10 @@ test('season 1 resolves to the anchor without relation lookups', async () => {
|
|||||||
assert.deepEqual(relationLookups, []);
|
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 { execute } = createExecutor(OREGAIRU_SEARCH, OREGAIRU_RELATIONS);
|
||||||
const result = await resolveAnilistSeasonMedia(
|
const result = await resolveAnilistSeasonMedia(
|
||||||
{ title: 'My Teen Romantic Comedy SNAFU', season: 2, episode: 1 },
|
{ 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?.id, 20698);
|
||||||
assert.equal(result?.via, 'sequel-chain');
|
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);
|
assert.equal(result?.exactTitleMatch, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -370,10 +370,20 @@ export async function resolveAnilistSeasonMedia(
|
|||||||
episode: season === null || season <= 1 ? input.episode : null,
|
episode: season === null || season <= 1 ? input.episode : null,
|
||||||
});
|
});
|
||||||
if (!anchor) return 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) {
|
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;
|
let chainError: unknown = null;
|
||||||
@@ -387,7 +397,7 @@ export async function resolveAnilistSeasonMedia(
|
|||||||
deps.logInfo?.(
|
deps.logInfo?.(
|
||||||
`[anilist] season ${season} of "${searchTitle}" resolved via sequel chain: ${displayTitle(viaChain, searchTitle)} (${viaChain.id})`,
|
`[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);
|
const viaAirOrder = pickByAirOrder(anchor, season, media);
|
||||||
@@ -395,7 +405,14 @@ export async function resolveAnilistSeasonMedia(
|
|||||||
deps.logInfo?.(
|
deps.logInfo?.(
|
||||||
`[anilist] season ${season} of "${searchTitle}" resolved via air order: ${displayTitle(viaAirOrder, searchTitle)} (${viaAirOrder.id})`,
|
`[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;
|
// The chain failed for transport reasons rather than because the season is absent;
|
||||||
@@ -407,5 +424,5 @@ export async function resolveAnilistSeasonMedia(
|
|||||||
deps.logInfo?.(
|
deps.logInfo?.(
|
||||||
`[anilist] could not resolve season ${season} of "${searchTitle}"; falling back to ${displayTitle(anchor, searchTitle)} (${anchor.id})`,
|
`[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';
|
} from './immersion-tracker/storage';
|
||||||
import {
|
import {
|
||||||
applySessionLifetimeSummary,
|
applySessionLifetimeSummary,
|
||||||
|
recomputeLifetimeAnimeAggregates,
|
||||||
reconcileStaleActiveSessions,
|
reconcileStaleActiveSessions,
|
||||||
rebuildLifetimeSummaries as rebuildLifetimeSummaryTables,
|
rebuildLifetimeSummaries as rebuildLifetimeSummaryTables,
|
||||||
shouldBackfillLifetimeSummaries,
|
shouldBackfillLifetimeSummaries,
|
||||||
@@ -526,7 +527,7 @@ export class ImmersionTrackerService {
|
|||||||
this.logger.info(
|
this.logger.info(
|
||||||
`Repaired season-scoped stats links on startup: scanned=${seasonRepair.scanned} movedVideos=${seasonRepair.movedVideos} deletedAnimeRows=${seasonRepair.deletedAnimeRows}`,
|
`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)) {
|
if (shouldBackfillLifetimeSummaries(this.db)) {
|
||||||
const result = rebuildLifetimeSummaryTables(this.db);
|
const result = rebuildLifetimeSummaryTables(this.db);
|
||||||
@@ -924,7 +925,7 @@ export class ImmersionTrackerService {
|
|||||||
animeId,
|
animeId,
|
||||||
);
|
);
|
||||||
if (repair.movedVideos > 0 || repair.deletedAnimeRows > 0) {
|
if (repair.movedVideos > 0 || repair.deletedAnimeRows > 0) {
|
||||||
rebuildLifetimeSummaryTables(this.db);
|
recomputeLifetimeAnimeAggregates(this.db);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update cover art for all videos in this anime
|
// Update cover art for all videos in this anime
|
||||||
@@ -1372,7 +1373,7 @@ export class ImmersionTrackerService {
|
|||||||
metadataJson: candidate.metadataJson,
|
metadataJson: candidate.metadataJson,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
rebuildLifetimeSummaryTables(this.db);
|
recomputeLifetimeAnimeAggregates(this.db);
|
||||||
}
|
}
|
||||||
|
|
||||||
recordJellyfinPlaybackMetadata(metadata: JellyfinPlaybackMetadataInput): void {
|
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),
|
this.db.prepare('SELECT 1 FROM imm_lifetime_media WHERE video_id = ?').get(videoId),
|
||||||
);
|
);
|
||||||
if (hasLifetimeMedia || (previousLink && previousLink.animeId !== animeId)) {
|
if (hasLifetimeMedia || (previousLink && previousLink.animeId !== animeId)) {
|
||||||
rebuildLifetimeSummaryTables(this.db);
|
recomputeLifetimeAnimeAggregates(this.db);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -72,7 +72,10 @@ interface EpisodeSeed {
|
|||||||
cards?: number;
|
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 {
|
function insertEpisode(db: DatabaseSync, seed: EpisodeSeed): void {
|
||||||
const activeMs = seed.activeMs ?? 1000;
|
const activeMs = seed.activeMs ?? 1000;
|
||||||
const cards = seed.cards ?? 1;
|
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)
|
`INSERT INTO imm_subtitle_lines(session_id, video_id, anime_id, line_index, text, CREATED_DATE, LAST_UPDATE_DATE)
|
||||||
VALUES (?, ?, ?, 1, ?, ?, ?)`,
|
VALUES (?, ?, ?, 1, ?, ?, ?)`,
|
||||||
).run(seed.videoId, seed.videoId, seed.animeId, `line ${seed.videoId}`, BASE_MS, BASE_MS);
|
).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[] {
|
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', () => {
|
test('mergeAnimeRecords repoints subtitle lines recorded before the anime link landed', () => {
|
||||||
withDb((db) => {
|
withDb((db) => {
|
||||||
insertAnime(db, { animeId: 1, key: 'show', title: 'Show' });
|
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), []);
|
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 type { DatabaseSync } from './sqlite';
|
||||||
import { rebuildLifetimeSummariesInTransaction } from './lifetime';
|
import { recomputeLifetimeAnimeAggregatesInTransaction } from './lifetime';
|
||||||
import { toDbTimestamp } from './query-shared';
|
import { toDbTimestamp } from './query-shared';
|
||||||
import { nowMs } from './time';
|
import { nowMs } from './time';
|
||||||
|
|
||||||
@@ -151,8 +151,9 @@ function absorbAnimeMetadata(
|
|||||||
* is repointed, metadata the target is missing is inherited from the sources,
|
* is repointed, metadata the target is missing is inherited from the sources,
|
||||||
* and the emptied source rows are deleted.
|
* and the emptied source rows are deleted.
|
||||||
*
|
*
|
||||||
* Assumes the caller already holds a write transaction and rebuilds the
|
* Assumes the caller already holds a write transaction and refreshes the
|
||||||
* lifetime summaries afterwards; use {@link mergeAnimeRecords} otherwise.
|
* per-anime lifetime aggregates afterwards; use {@link mergeAnimeRecords}
|
||||||
|
* otherwise.
|
||||||
*/
|
*/
|
||||||
export function mergeAnimeRecordsInTransaction(
|
export function mergeAnimeRecordsInTransaction(
|
||||||
db: DatabaseSync,
|
db: DatabaseSync,
|
||||||
@@ -237,7 +238,7 @@ export function mergeAnimeRecords(
|
|||||||
return runInTransaction(db, () => {
|
return runInTransaction(db, () => {
|
||||||
const summary = mergeAnimeRecordsInTransaction(db, targetAnimeId, sourceAnimeIds);
|
const summary = mergeAnimeRecordsInTransaction(db, targetAnimeId, sourceAnimeIds);
|
||||||
if (summary.mergedAnimeIds.length > 0) {
|
if (summary.mergedAnimeIds.length > 0) {
|
||||||
rebuildLifetimeSummariesInTransaction(db);
|
recomputeLifetimeAnimeAggregatesInTransaction(db);
|
||||||
}
|
}
|
||||||
return summary;
|
return summary;
|
||||||
});
|
});
|
||||||
@@ -286,7 +287,7 @@ export function moveVideoToAnime(
|
|||||||
removedPreviousAnime = true;
|
removedPreviousAnime = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
rebuildLifetimeSummariesInTransaction(db);
|
recomputeLifetimeAnimeAggregatesInTransaction(db);
|
||||||
return { targetAnimeId, previousAnimeId, removedPreviousAnime };
|
return { targetAnimeId, previousAnimeId, removedPreviousAnime };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -380,8 +380,12 @@ export function resolveAnimeAnilistConflict(
|
|||||||
targetRow.anilist_id !== anilistId
|
targetRow.anilist_id !== anilistId
|
||||||
) {
|
) {
|
||||||
// An automatic lookup disagreeing with an existing explicit link is a
|
// An automatic lookup disagreeing with an existing explicit link is a
|
||||||
// mis-resolution, not evidence that either row should move or merge.
|
// mis-resolution, not evidence that either row should move or merge. The
|
||||||
return emptySummary(1);
|
// 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';
|
const isManual = options.survivor === 'target' || options.matchConfidence === 'manual';
|
||||||
if (!isManual && hasDismissedAnimeMergeRecommendation(db, targetAnimeId, conflict.animeId)) {
|
if (!isManual && hasDismissedAnimeMergeRecommendation(db, targetAnimeId, conflict.animeId)) {
|
||||||
|
|||||||
@@ -708,6 +708,87 @@ export function rebuildLifetimeSummariesInTransaction(
|
|||||||
return rebuildLifetimeSummariesInternal(db, rebuiltAtMs);
|
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 {
|
export function reconcileStaleActiveSessions(db: DatabaseSync): number {
|
||||||
const sessions = getRetainedStaleActiveSessions(db);
|
const sessions = getRetainedStaleActiveSessions(db);
|
||||||
if (sessions.length === 0) {
|
if (sessions.length === 0) {
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
import type { DatabaseSync } from './sqlite';
|
import type { DatabaseSync } from './sqlite';
|
||||||
import { buildCoverBlobReference, normalizeCoverBlobBytes } from './storage';
|
import { buildCoverBlobReference, normalizeCoverBlobBytes } from './storage';
|
||||||
import { rebuildLifetimeSummaries, rebuildLifetimeSummariesInTransaction } from './lifetime';
|
import {
|
||||||
|
recomputeLifetimeAnimeAggregates,
|
||||||
|
rebuildLifetimeSummariesInTransaction,
|
||||||
|
} from './lifetime';
|
||||||
import { getRollupGroupsForSessions, refreshRollupsForGroupsInTransaction } from './maintenance';
|
import { getRollupGroupsForSessions, refreshRollupsForGroupsInTransaction } from './maintenance';
|
||||||
import { nowMs } from './time';
|
import { nowMs } from './time';
|
||||||
import { resolveAnimeAnilistConflict } from './anime-season-repair';
|
import { resolveAnimeAnilistConflict } from './anime-season-repair';
|
||||||
@@ -460,7 +463,7 @@ export function updateAnimeAnilistInfo(
|
|||||||
targetRow.anime_id,
|
targetRow.anime_id,
|
||||||
);
|
);
|
||||||
if (repair.movedVideos > 0 || repair.deletedAnimeRows > 0) {
|
if (repair.movedVideos > 0 || repair.deletedAnimeRows > 0) {
|
||||||
rebuildLifetimeSummaries(db);
|
recomputeLifetimeAnimeAggregates(db);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user