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:
@@ -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