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
|
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.
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user