mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-06-09 15:13:32 -07:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d5bfdcae7b |
@@ -2,4 +2,5 @@ type: changed
|
||||
area: stats
|
||||
|
||||
- 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
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.
|
||||
|
||||
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 () => {
|
||||
const dbPath = makeDbPath();
|
||||
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 () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
|
||||
@@ -91,6 +91,10 @@ import {
|
||||
upsertCoverArt,
|
||||
} from './immersion-tracker/query-maintenance';
|
||||
import { repairJellyfinStreamVideoLinks } from './immersion-tracker/jellyfin-link-repair';
|
||||
import {
|
||||
repairLegacySeasonlessAnimeRows,
|
||||
resolveAnimeAnilistConflict,
|
||||
} from './immersion-tracker/anime-season-repair';
|
||||
import {
|
||||
buildVideoKey,
|
||||
deriveCanonicalTitle,
|
||||
@@ -475,6 +479,13 @@ export class ImmersionTrackerService {
|
||||
`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)) {
|
||||
const result = rebuildLifetimeSummaryTables(this.db);
|
||||
if (result.appliedSessions > 0) {
|
||||
@@ -733,6 +744,7 @@ export class ImmersionTrackerService {
|
||||
coverUrl?: string | null;
|
||||
},
|
||||
): Promise<void> {
|
||||
const repair = resolveAnimeAnilistConflict(this.db, animeId, info.anilistId);
|
||||
this.db
|
||||
.prepare(
|
||||
`
|
||||
@@ -758,6 +770,9 @@ export class ImmersionTrackerService {
|
||||
nowMs(),
|
||||
animeId,
|
||||
);
|
||||
if (repair.movedVideos > 0 || repair.deletedAnimeRows > 0) {
|
||||
rebuildLifetimeSummaryTables(this.db);
|
||||
}
|
||||
|
||||
// Update cover art for all videos in this anime
|
||||
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', () => {
|
||||
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 type { DatabaseSync } from './sqlite';
|
||||
import { buildCoverBlobReference, normalizeCoverBlobBytes } from './storage';
|
||||
import { rebuildLifetimeSummariesInTransaction } from './lifetime';
|
||||
import { rebuildLifetimeSummaries, rebuildLifetimeSummariesInTransaction } from './lifetime';
|
||||
import { getRollupGroupsForSessions, refreshRollupsForGroupsInTransaction } from './maintenance';
|
||||
import { nowMs } from './time';
|
||||
import { resolveAnimeAnilistConflict } from './anime-season-repair';
|
||||
import { PartOfSpeech, type MergedToken } from '../../../types';
|
||||
import { shouldExcludeTokenFromVocabularyPersistence } from '../tokenizer/annotation-stage';
|
||||
import { deriveStoredPartOfSpeech } from '../tokenizer/part-of-speech';
|
||||
@@ -425,6 +426,14 @@ export function updateAnimeAnilistInfo(
|
||||
} | null;
|
||||
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(
|
||||
`
|
||||
UPDATE imm_anime
|
||||
@@ -444,8 +453,11 @@ export function updateAnimeAnilistInfo(
|
||||
info.titleNative,
|
||||
info.episodesTotal,
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user