fix(stats): repair legacy combined-season anime rows on startup (#116)

This commit is contained in:
2026-06-09 12:41:07 -07:00
committed by GitHub
parent 311f1e8ee5
commit d5bfdcae7b
7 changed files with 952 additions and 2 deletions
+1
View File
@@ -2,4 +2,5 @@ type: changed
area: stats area: stats
- Split local and Jellyfin library entries by detected season, using season folders first and filename parsing as fallback. - Split local and Jellyfin library entries by detected season, using season folders first and filename parsing as fallback.
- Repaired older combined-series stats rows by moving parsed episodes into season-specific library entries, rebuilding summaries, and deleting now-empty legacy rows.
- Refresh anime detail and library cover art immediately after manually changing an AniList entry. - Refresh anime detail and library cover art immediately after manually changing an AniList entry.
+2
View File
@@ -50,6 +50,8 @@ Cover-art library with search and sorting, per-series progress, episode drill-do
Local files and Jellyfin items with detected season numbers are split into season-specific library entries, so `Season 1` and `Season 2` folders do not merge into one show card. Local files and Jellyfin items with detected season numbers are split into season-specific library entries, so `Season 1` and `Season 2` folders do not merge into one show card.
When older stats already grouped multiple seasons under one series entry, SubMiner moves parsed episodes into the season-specific entries on startup and rebuilds the affected summaries.
Jellyfin stream URLs are normalized to stable item links before stats titles are shown, so playback query parameters are not displayed in the dashboard. Jellyfin stream URLs are normalized to stable item links before stats titles are shown, so playback query parameters are not displayed in the dashboard.
When YouTube channel metadata is available, the Library tab groups videos by creator/channel and treats each tracked video as an episode-like entry inside that channel section. When YouTube channel metadata is available, the Library tab groups videos by creator/channel and treats each tracked video as an episode-like entry inside that channel section.
@@ -1676,6 +1676,276 @@ test('handleMediaChange splits matching parsed titles across distinct seasons',
} }
}); });
test('startup redistributes legacy combined anime rows across parsed seasons', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath });
const privateApi = tracker as unknown as { db: DatabaseSync };
privateApi.db.exec(`
INSERT INTO imm_anime (
anime_id,
normalized_title_key,
canonical_title,
anilist_id,
title_romaji,
CREATED_DATE,
LAST_UPDATE_DATE
) VALUES (
1,
'frieren',
'Frieren',
154587,
'Sousou no Frieren',
1000,
1000
);
INSERT INTO imm_videos (
video_id,
video_key,
canonical_title,
anime_id,
source_type,
source_path,
parsed_basename,
parsed_title,
parsed_season,
parsed_episode,
parser_source,
parser_confidence,
watched,
duration_ms,
CREATED_DATE,
LAST_UPDATE_DATE
) VALUES
(
1,
'local:/tmp/Frieren S01E01.mkv',
'Frieren S01E01',
1,
1,
'/tmp/Frieren S01E01.mkv',
'Frieren S01E01.mkv',
'Frieren',
1,
1,
'fallback',
0.9,
1,
0,
1000,
1000
),
(
2,
'local:/tmp/Frieren S02E01.mkv',
'Frieren S02E01',
1,
1,
'/tmp/Frieren S02E01.mkv',
'Frieren S02E01.mkv',
'Frieren',
2,
1,
'fallback',
0.9,
1,
0,
1000,
1000
);
INSERT INTO imm_sessions (
session_id,
session_uuid,
video_id,
started_at_ms,
ended_at_ms,
status,
CREATED_DATE,
LAST_UPDATE_DATE
) VALUES
(1, 'season-repair-session-1', 1, 1000, 2000, 2, 1000, 2000),
(2, 'season-repair-session-2', 2, 3000, 4000, 2, 3000, 4000);
INSERT INTO imm_session_telemetry (
session_id,
sample_ms,
total_watched_ms,
active_watched_ms,
lines_seen,
tokens_seen,
cards_mined,
lookup_count,
lookup_hits,
pause_count,
pause_ms,
seek_forward_count,
seek_backward_count,
media_buffer_events
) VALUES
(1, 2000, 1000, 1000, 1, 10, 1, 0, 0, 0, 0, 0, 0, 0),
(2, 4000, 2000, 2000, 2, 20, 2, 0, 0, 0, 0, 0, 0, 0);
`);
tracker.destroy();
tracker = new Ctor({ dbPath });
const repairedApi = tracker as unknown as { db: DatabaseSync };
const rows = repairedApi.db
.prepare(
`
SELECT
a.canonical_title AS canonicalTitle,
a.normalized_title_key AS normalizedTitleKey,
a.anilist_id AS anilistId,
COUNT(v.video_id) AS videoCount,
COALESCE(lm.total_active_ms, 0) AS totalActiveMs
FROM imm_anime a
LEFT JOIN imm_videos v ON v.anime_id = a.anime_id
LEFT JOIN imm_lifetime_anime lm ON lm.anime_id = a.anime_id
GROUP BY a.anime_id
ORDER BY a.canonical_title ASC
`,
)
.all() as Array<{
canonicalTitle: string;
normalizedTitleKey: string;
anilistId: number | null;
videoCount: number;
totalActiveMs: number;
}>;
assert.deepEqual(rows, [
{
canonicalTitle: 'Frieren Season 1',
normalizedTitleKey: 'frieren season 1',
anilistId: 154587,
videoCount: 1,
totalActiveMs: 1000,
},
{
canonicalTitle: 'Frieren Season 2',
normalizedTitleKey: 'frieren season 2',
anilistId: null,
videoCount: 1,
totalActiveMs: 2000,
},
]);
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
test('startup skips single-season anime rows during legacy season repair', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath });
const privateApi = tracker as unknown as { db: DatabaseSync };
privateApi.db.exec(`
INSERT INTO imm_anime (
anime_id,
normalized_title_key,
canonical_title,
anilist_id,
title_romaji,
CREATED_DATE,
LAST_UPDATE_DATE
) VALUES (
1,
'frieren',
'Frieren',
154587,
'Sousou no Frieren',
1000,
1000
);
INSERT INTO imm_videos (
video_id,
video_key,
canonical_title,
anime_id,
source_type,
source_path,
parsed_basename,
parsed_title,
parsed_season,
parsed_episode,
parser_source,
parser_confidence,
watched,
duration_ms,
CREATED_DATE,
LAST_UPDATE_DATE
) VALUES (
1,
'local:/tmp/Frieren S01E01.mkv',
'Frieren S01E01',
1,
1,
'/tmp/Frieren S01E01.mkv',
'Frieren S01E01.mkv',
'Frieren',
1,
1,
'fallback',
0.9,
1,
0,
1000,
1000
);
`);
tracker.destroy();
tracker = new Ctor({ dbPath });
const repairedApi = tracker as unknown as { db: DatabaseSync };
const rows = repairedApi.db
.prepare(
`
SELECT
a.canonical_title AS canonicalTitle,
a.normalized_title_key AS normalizedTitleKey,
a.anilist_id AS anilistId,
COUNT(v.video_id) AS videoCount
FROM imm_anime a
LEFT JOIN imm_videos v ON v.anime_id = a.anime_id
GROUP BY a.anime_id
ORDER BY a.anime_id ASC
`,
)
.all() as Array<{
canonicalTitle: string;
normalizedTitleKey: string;
anilistId: number | null;
videoCount: number;
}>;
assert.deepEqual(rows, [
{
canonicalTitle: 'Frieren',
normalizedTitleKey: 'frieren',
anilistId: 154587,
videoCount: 1,
},
]);
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
test('Jellyfin playback metadata links stream videos to existing series title', async () => { test('Jellyfin playback metadata links stream videos to existing series title', async () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null; let tracker: ImmersionTrackerService | null = null;
@@ -2847,6 +3117,216 @@ test('reassignAnimeAnilist preserves existing description when description is om
} }
}); });
test('reassignAnimeAnilist redistributes conflicting legacy combined row before assigning AniList id', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath });
const privateApi = tracker as unknown as { db: DatabaseSync };
privateApi.db.exec(`
INSERT INTO imm_anime (
anime_id,
normalized_title_key,
canonical_title,
anilist_id,
title_romaji,
CREATED_DATE,
LAST_UPDATE_DATE
) VALUES
(
1,
'konosuba',
'KonoSuba',
21202,
'Kono Subarashii Sekai ni Shukufuku wo!',
1000,
1000
),
(
2,
'konosuba season 1',
'KonoSuba Season 1',
NULL,
NULL,
1000,
1000
);
INSERT INTO imm_videos (
video_id,
video_key,
canonical_title,
anime_id,
source_type,
source_path,
parsed_basename,
parsed_title,
parsed_season,
parsed_episode,
parser_source,
parser_confidence,
watched,
duration_ms,
CREATED_DATE,
LAST_UPDATE_DATE
) VALUES
(
1,
'local:/tmp/KonoSuba S01E01.mkv',
'KonoSuba S01E01',
1,
1,
'/tmp/KonoSuba S01E01.mkv',
'KonoSuba S01E01.mkv',
'KonoSuba',
1,
1,
'fallback',
0.9,
1,
0,
1000,
1000
),
(
2,
'local:/tmp/KonoSuba S02E01.mkv',
'KonoSuba S02E01',
1,
1,
'/tmp/KonoSuba S02E01.mkv',
'KonoSuba S02E01.mkv',
'KonoSuba',
2,
1,
'fallback',
0.9,
1,
0,
1000,
1000
),
(
3,
'local:/tmp/KonoSuba S01E02.mkv',
'KonoSuba S01E02',
2,
1,
'/tmp/KonoSuba S01E02.mkv',
'KonoSuba S01E02.mkv',
'KonoSuba',
1,
2,
'fallback',
0.9,
1,
0,
1000,
1000
);
INSERT INTO imm_sessions (
session_id,
session_uuid,
video_id,
started_at_ms,
ended_at_ms,
status,
CREATED_DATE,
LAST_UPDATE_DATE
) VALUES
(1, 'anilist-conflict-session-1', 1, 1000, 2000, 2, 1000, 2000),
(2, 'anilist-conflict-session-2', 2, 3000, 4000, 2, 3000, 4000),
(3, 'anilist-conflict-session-3', 3, 5000, 6000, 2, 5000, 6000);
INSERT INTO imm_subtitle_lines (
session_id,
video_id,
anime_id,
line_index,
text,
CREATED_DATE,
LAST_UPDATE_DATE
) VALUES
(1, 1, 1, 0, 'season one legacy line', 1000, 1000),
(2, 2, 1, 0, 'season two legacy line', 1000, 1000);
INSERT INTO imm_session_telemetry (
session_id,
sample_ms,
total_watched_ms,
active_watched_ms,
lines_seen,
tokens_seen,
cards_mined,
lookup_count,
lookup_hits,
pause_count,
pause_ms,
seek_forward_count,
seek_backward_count,
media_buffer_events
) VALUES
(1, 2000, 1000, 1000, 1, 10, 0, 0, 0, 0, 0, 0, 0, 0),
(2, 4000, 2000, 2000, 2, 20, 0, 0, 0, 0, 0, 0, 0, 0),
(3, 6000, 3000, 3000, 3, 30, 0, 0, 0, 0, 0, 0, 0, 0);
`);
await tracker.reassignAnimeAnilist(2, {
anilistId: 21202,
titleRomaji: 'Kono Subarashii Sekai ni Shukufuku wo!',
});
const rows = privateApi.db
.prepare(
`
SELECT
a.canonical_title AS canonicalTitle,
a.anilist_id AS anilistId,
COUNT(DISTINCT v.video_id) AS videoCount,
COUNT(DISTINCT sl.line_id) AS subtitleLineCount,
COALESCE(lm.total_active_ms, 0) AS totalActiveMs
FROM imm_anime a
LEFT JOIN imm_videos v ON v.anime_id = a.anime_id
LEFT JOIN imm_subtitle_lines sl ON sl.anime_id = a.anime_id
LEFT JOIN imm_lifetime_anime lm ON lm.anime_id = a.anime_id
GROUP BY a.anime_id
ORDER BY a.canonical_title ASC
`,
)
.all() as Array<{
canonicalTitle: string;
anilistId: number | null;
videoCount: number;
subtitleLineCount: number;
totalActiveMs: number;
}>;
assert.deepEqual(rows, [
{
canonicalTitle: 'KonoSuba Season 1',
anilistId: 21202,
videoCount: 2,
subtitleLineCount: 1,
totalActiveMs: 4000,
},
{
canonicalTitle: 'KonoSuba Season 2',
anilistId: null,
videoCount: 1,
subtitleLineCount: 1,
totalActiveMs: 2000,
},
]);
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
test('handleMediaChange stores youtube metadata for new youtube sessions', async () => { test('handleMediaChange stores youtube metadata for new youtube sessions', async () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null; let tracker: ImmersionTrackerService | null = null;
@@ -91,6 +91,10 @@ import {
upsertCoverArt, upsertCoverArt,
} from './immersion-tracker/query-maintenance'; } from './immersion-tracker/query-maintenance';
import { repairJellyfinStreamVideoLinks } from './immersion-tracker/jellyfin-link-repair'; import { repairJellyfinStreamVideoLinks } from './immersion-tracker/jellyfin-link-repair';
import {
repairLegacySeasonlessAnimeRows,
resolveAnimeAnilistConflict,
} from './immersion-tracker/anime-season-repair';
import { import {
buildVideoKey, buildVideoKey,
deriveCanonicalTitle, deriveCanonicalTitle,
@@ -475,6 +479,13 @@ export class ImmersionTrackerService {
`Repaired Jellyfin stats links on startup: scanned=${jellyfinRepair.scanned} repaired=${jellyfinRepair.repaired}`, `Repaired Jellyfin stats links on startup: scanned=${jellyfinRepair.scanned} repaired=${jellyfinRepair.repaired}`,
); );
} }
const seasonRepair = repairLegacySeasonlessAnimeRows(this.db);
if (seasonRepair.movedVideos > 0 || seasonRepair.deletedAnimeRows > 0) {
this.logger.info(
`Repaired season-scoped stats links on startup: scanned=${seasonRepair.scanned} movedVideos=${seasonRepair.movedVideos} deletedAnimeRows=${seasonRepair.deletedAnimeRows}`,
);
rebuildLifetimeSummaryTables(this.db);
}
if (shouldBackfillLifetimeSummaries(this.db)) { if (shouldBackfillLifetimeSummaries(this.db)) {
const result = rebuildLifetimeSummaryTables(this.db); const result = rebuildLifetimeSummaryTables(this.db);
if (result.appliedSessions > 0) { if (result.appliedSessions > 0) {
@@ -733,6 +744,7 @@ export class ImmersionTrackerService {
coverUrl?: string | null; coverUrl?: string | null;
}, },
): Promise<void> { ): Promise<void> {
const repair = resolveAnimeAnilistConflict(this.db, animeId, info.anilistId);
this.db this.db
.prepare( .prepare(
` `
@@ -758,6 +770,9 @@ export class ImmersionTrackerService {
nowMs(), nowMs(),
animeId, animeId,
); );
if (repair.movedVideos > 0 || repair.deletedAnimeRows > 0) {
rebuildLifetimeSummaryTables(this.db);
}
// Update cover art for all videos in this anime // Update cover art for all videos in this anime
if (info.coverUrl) { if (info.coverUrl) {
@@ -680,6 +680,116 @@ test('split maintenance helpers update anime metadata and watched state', () =>
} }
}); });
test('updateAnimeAnilistInfo redistributes legacy combined row before assigning duplicate AniList id', () => {
const { db, dbPath } = createDb();
try {
const legacyAnimeId = getOrCreateAnimeRecord(db, {
parsedTitle: 'KonoSuba',
canonicalTitle: 'KonoSuba',
anilistId: 21202,
titleRomaji: 'Kono Subarashii Sekai ni Shukufuku wo!',
titleEnglish: null,
titleNative: null,
metadataJson: null,
});
const seasonAnimeId = getOrCreateAnimeRecord(db, {
parsedTitle: 'KonoSuba',
canonicalTitle: 'KonoSuba',
seasonScope: 1,
anilistId: null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
metadataJson: null,
});
const legacySeasonOneVideoId = getOrCreateVideoRecord(db, 'local:/tmp/konosuba-s01e01.mkv', {
canonicalTitle: 'KonoSuba S01E01',
sourcePath: '/tmp/konosuba-s01e01.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
const legacySeasonTwoVideoId = getOrCreateVideoRecord(db, 'local:/tmp/konosuba-s02e01.mkv', {
canonicalTitle: 'KonoSuba S02E01',
sourcePath: '/tmp/konosuba-s02e01.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
const targetVideoId = getOrCreateVideoRecord(db, 'local:/tmp/konosuba-s01e02.mkv', {
canonicalTitle: 'KonoSuba S01E02',
sourcePath: '/tmp/konosuba-s01e02.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
linkVideoToAnimeRecord(db, legacySeasonOneVideoId, {
animeId: legacyAnimeId,
parsedBasename: 'konosuba-s01e01.mkv',
parsedTitle: 'KonoSuba',
parsedSeason: 1,
parsedEpisode: 1,
parserSource: 'test',
parserConfidence: 1,
parseMetadataJson: null,
});
linkVideoToAnimeRecord(db, legacySeasonTwoVideoId, {
animeId: legacyAnimeId,
parsedBasename: 'konosuba-s02e01.mkv',
parsedTitle: 'KonoSuba',
parsedSeason: 2,
parsedEpisode: 1,
parserSource: 'test',
parserConfidence: 1,
parseMetadataJson: null,
});
linkVideoToAnimeRecord(db, targetVideoId, {
animeId: seasonAnimeId,
parsedBasename: 'konosuba-s01e02.mkv',
parsedTitle: 'KonoSuba',
parsedSeason: 1,
parsedEpisode: 2,
parserSource: 'test',
parserConfidence: 1,
parseMetadataJson: null,
});
updateAnimeAnilistInfo(db, targetVideoId, {
anilistId: 21202,
titleRomaji: 'Kono Subarashii Sekai ni Shukufuku wo!',
titleEnglish: null,
titleNative: null,
episodesTotal: 10,
});
const rows = db
.prepare(
`
SELECT
a.canonical_title AS canonicalTitle,
a.anilist_id AS anilistId,
COUNT(v.video_id) AS videoCount
FROM imm_anime a
LEFT JOIN imm_videos v ON v.anime_id = a.anime_id
GROUP BY a.anime_id
ORDER BY a.canonical_title ASC
`,
)
.all() as Array<{
canonicalTitle: string;
anilistId: number | null;
videoCount: number;
}>;
assert.deepEqual(rows, [
{ canonicalTitle: 'KonoSuba Season 1', anilistId: 21202, videoCount: 2 },
{ canonicalTitle: 'KonoSuba Season 2', anilistId: null, videoCount: 1 },
]);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('deleteSessions refreshes only rollups affected by deleted sessions', () => { test('deleteSessions refreshes only rollups affected by deleted sessions', () => {
const { db, dbPath } = createDb(); const { db, dbPath } = createDb();
@@ -0,0 +1,330 @@
import type { DatabaseSync } from './sqlite';
import { getOrCreateAnimeRecord } from './storage';
import { toDbTimestamp } from './query-shared';
import { nowMs } from './time';
export interface AnimeSeasonRepairSummary {
scanned: number;
repaired: number;
movedVideos: number;
deletedAnimeRows: number;
}
interface AnimeRow {
anime_id: number;
anilist_id: number | null;
title_romaji: string | null;
title_english: string | null;
title_native: string | null;
episodes_total: number | null;
description: string | null;
}
interface ParsedVideoRow {
video_id: number;
parsed_title: string | null;
parsed_season: number | null;
}
interface RedistributeOptions {
transferAnilistToAnimeId?: number | null;
transferLegacyAnilist?: boolean;
overwriteTargetAnilist?: boolean;
}
function emptySummary(scanned = 0): AnimeSeasonRepairSummary {
return {
scanned,
repaired: 0,
movedVideos: 0,
deletedAnimeRows: 0,
};
}
function mergeSummary(
target: AnimeSeasonRepairSummary,
source: AnimeSeasonRepairSummary,
): AnimeSeasonRepairSummary {
target.scanned += source.scanned;
target.repaired += source.repaired;
target.movedVideos += source.movedVideos;
target.deletedAnimeRows += source.deletedAnimeRows;
return target;
}
function runInTransaction<T>(db: DatabaseSync, work: () => T): T {
db.exec('BEGIN');
try {
const result = work();
db.exec('COMMIT');
return result;
} catch (error) {
db.exec('ROLLBACK');
throw error;
}
}
function normalizeSeason(value: number | null): number | null {
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) {
return null;
}
return value;
}
function getAnimeRow(db: DatabaseSync, animeId: number): AnimeRow | null {
return db
.prepare(
`
SELECT
anime_id,
anilist_id,
title_romaji,
title_english,
title_native,
episodes_total,
description
FROM imm_anime
WHERE anime_id = ?
`,
)
.get(animeId) as AnimeRow | null;
}
function getParsedVideos(db: DatabaseSync, animeId: number): ParsedVideoRow[] {
return db
.prepare(
`
SELECT video_id, parsed_title, parsed_season
FROM imm_videos
WHERE anime_id = ?
ORDER BY video_id ASC
`,
)
.all(animeId) as ParsedVideoRow[];
}
function hasAnimeReferences(db: DatabaseSync, animeId: number): boolean {
const row = db
.prepare(
`
SELECT 1 AS found
WHERE EXISTS (SELECT 1 FROM imm_videos WHERE anime_id = ?)
OR EXISTS (SELECT 1 FROM imm_subtitle_lines WHERE anime_id = ?)
`,
)
.get(animeId, animeId) as { found: number } | null;
return Boolean(row);
}
function assignAnilistToTarget(
db: DatabaseSync,
source: AnimeRow,
targetAnimeId: number,
overwriteTarget: boolean,
updatedAt: string,
): boolean {
if (source.anilist_id === null || targetAnimeId === source.anime_id) {
return false;
}
const target = getAnimeRow(db, targetAnimeId);
if (!target) {
return false;
}
if (!overwriteTarget && target.anilist_id !== null && target.anilist_id !== source.anilist_id) {
return false;
}
db.prepare(
`
UPDATE imm_anime
SET anilist_id = NULL,
LAST_UPDATE_DATE = ?
WHERE anime_id = ?
`,
).run(updatedAt, source.anime_id);
const updated = db
.prepare(
`
UPDATE imm_anime
SET
anilist_id = ?,
title_romaji = COALESCE(?, title_romaji),
title_english = COALESCE(?, title_english),
title_native = COALESCE(?, title_native),
episodes_total = COALESCE(?, episodes_total),
description = COALESCE(?, description),
LAST_UPDATE_DATE = ?
WHERE anime_id = ?
`,
)
.run(
source.anilist_id,
source.title_romaji,
source.title_english,
source.title_native,
source.episodes_total,
source.description,
updatedAt,
targetAnimeId,
) as { changes: number };
return updated.changes > 0;
}
function redistributeAnimeRowByParsedSeasonsInTransaction(
db: DatabaseSync,
animeId: number,
options: RedistributeOptions = {},
): AnimeSeasonRepairSummary {
const source = getAnimeRow(db, animeId);
if (!source) {
return emptySummary(1);
}
const videos = getParsedVideos(db, animeId);
const summary = emptySummary(1);
const updatedAt = toDbTimestamp(nowMs());
const targetBySeason = new Map<number, number>();
for (const video of videos) {
const parsedTitle = video.parsed_title?.trim();
const season = normalizeSeason(video.parsed_season);
if (!parsedTitle || season === null) {
continue;
}
const targetAnimeId = getOrCreateAnimeRecord(db, {
parsedTitle,
canonicalTitle: parsedTitle,
seasonScope: season,
anilistId: null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
metadataJson: null,
});
targetBySeason.set(season, targetAnimeId);
if (targetAnimeId === animeId) {
continue;
}
const videoUpdate = db
.prepare(
`
UPDATE imm_videos
SET anime_id = ?,
LAST_UPDATE_DATE = ?
WHERE video_id = ?
`,
)
.run(targetAnimeId, updatedAt, video.video_id) as { changes: number };
const lineUpdate = db
.prepare(
`
UPDATE imm_subtitle_lines
SET anime_id = ?,
LAST_UPDATE_DATE = ?
WHERE video_id = ?
`,
)
.run(targetAnimeId, updatedAt, video.video_id) as { changes: number };
if (videoUpdate.changes > 0 || lineUpdate.changes > 0) {
summary.movedVideos += 1;
}
}
const transferTarget =
options.transferAnilistToAnimeId ??
(options.transferLegacyAnilist
? (targetBySeason.get(1) ??
(targetBySeason.size === 1 ? [...targetBySeason.values()][0] : null))
: null);
if (transferTarget) {
const transferred = assignAnilistToTarget(
db,
source,
transferTarget,
options.overwriteTargetAnilist ?? false,
updatedAt,
);
if (transferred) {
summary.repaired += 1;
}
}
if (!hasAnimeReferences(db, animeId)) {
const deleted = db.prepare('DELETE FROM imm_anime WHERE anime_id = ?').run(animeId) as {
changes: number;
};
if (deleted.changes > 0) {
summary.deletedAnimeRows += 1;
}
}
if (summary.movedVideos > 0 || summary.deletedAnimeRows > 0) {
summary.repaired += 1;
}
return summary;
}
export function repairLegacySeasonlessAnimeRows(db: DatabaseSync): AnimeSeasonRepairSummary {
return runInTransaction(db, () => {
const candidates = db
.prepare(
`
SELECT a.anime_id AS animeId
FROM imm_anime a
JOIN imm_videos v ON v.anime_id = a.anime_id
WHERE v.parsed_title IS NOT NULL
AND TRIM(v.parsed_title) != ''
AND v.parsed_season IS NOT NULL
AND v.parsed_season > 0
GROUP BY a.anime_id
HAVING COUNT(DISTINCT v.parsed_season) > 1
ORDER BY a.anime_id ASC
`,
)
.all() as Array<{ animeId: number }>;
const summary = emptySummary();
for (const candidate of candidates) {
mergeSummary(
summary,
redistributeAnimeRowByParsedSeasonsInTransaction(db, candidate.animeId, {
transferLegacyAnilist: true,
}),
);
}
return summary;
});
}
export function resolveAnimeAnilistConflict(
db: DatabaseSync,
targetAnimeId: number,
anilistId: number,
): AnimeSeasonRepairSummary {
const conflict = db
.prepare(
`
SELECT anime_id AS animeId
FROM imm_anime
WHERE anilist_id = ?
AND anime_id != ?
LIMIT 1
`,
)
.get(anilistId, targetAnimeId) as { animeId: number } | null;
if (!conflict) {
return emptySummary();
}
return runInTransaction(db, () =>
redistributeAnimeRowByParsedSeasonsInTransaction(db, conflict.animeId, {
transferAnilistToAnimeId: targetAnimeId,
overwriteTargetAnilist: true,
}),
);
}
@@ -1,9 +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 { rebuildLifetimeSummariesInTransaction } from './lifetime'; import { rebuildLifetimeSummaries, 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 { PartOfSpeech, type MergedToken } from '../../../types'; import { PartOfSpeech, type MergedToken } from '../../../types';
import { shouldExcludeTokenFromVocabularyPersistence } from '../tokenizer/annotation-stage'; import { shouldExcludeTokenFromVocabularyPersistence } from '../tokenizer/annotation-stage';
import { deriveStoredPartOfSpeech } from '../tokenizer/part-of-speech'; import { deriveStoredPartOfSpeech } from '../tokenizer/part-of-speech';
@@ -425,6 +426,14 @@ export function updateAnimeAnilistInfo(
} | null; } | null;
if (!row?.anime_id) return; if (!row?.anime_id) return;
const repair = resolveAnimeAnilistConflict(db, row.anime_id, info.anilistId);
const targetRow = db
.prepare('SELECT anime_id FROM imm_videos WHERE video_id = ?')
.get(videoId) as {
anime_id: number | null;
} | null;
if (!targetRow?.anime_id) return;
db.prepare( db.prepare(
` `
UPDATE imm_anime UPDATE imm_anime
@@ -444,8 +453,11 @@ export function updateAnimeAnilistInfo(
info.titleNative, info.titleNative,
info.episodesTotal, info.episodesTotal,
toDbTimestamp(nowMs()), toDbTimestamp(nowMs()),
row.anime_id, targetRow.anime_id,
); );
if (repair.movedVideos > 0 || repair.deletedAnimeRows > 0) {
rebuildLifetimeSummaries(db);
}
} }
export function markVideoWatched(db: DatabaseSync, videoId: number, watched: boolean): void { export function markVideoWatched(db: DatabaseSync, videoId: number, watched: boolean): void {