fix(stats): preserve locked assignments during media relinking

- Keep manual YouTube assignments out of automatic regrouping
- Clear stale subtitle assignments when Jellyfin links are unassigned
This commit is contained in:
2026-08-14 02:21:45 -07:00
parent 18790481ff
commit 8c2ddb1f44
5 changed files with 188 additions and 16 deletions
@@ -2978,6 +2978,66 @@ test('Jellyfin link repair removes merged leaked anime rows and sanitizes orphan
}
});
test('Jellyfin link repair clears stale subtitle assignments when the repaired video is unassigned', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath });
const db = (tracker as unknown as { db: DatabaseSync }).db;
const timestamp = toDbTimestamp(trackerNowMs());
const legacyUrl =
'http://jellyfin.local/Videos/item-null/stream?static=true&api_key=secret-token';
const stableUrl = 'jellyfin://jellyfin.local/item/item-null';
db.prepare(
`INSERT INTO imm_anime (anime_id, normalized_title_key, canonical_title, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (1, 'stale show', 'Stale Show', ?, ?)`,
).run(timestamp, timestamp);
db.prepare(
`INSERT INTO imm_videos (
video_id, video_key, anime_id, canonical_title, source_type, source_url,
duration_ms, CREATED_DATE, LAST_UPDATE_DATE
) VALUES
(1, ?, 1, 'Legacy Stream', 2, ?, 0, ?, ?),
(2, ?, NULL, 'Canonical Stream', 2, ?, 0, ?, ?)`,
).run(
`remote:${legacyUrl}`,
legacyUrl,
timestamp,
timestamp,
`remote:${stableUrl}`,
stableUrl,
timestamp,
timestamp,
);
db.prepare(
`INSERT INTO imm_sessions (
session_id, session_uuid, video_id, started_at_ms, status, CREATED_DATE, LAST_UPDATE_DATE
) VALUES (1, 'jellyfin-null-assignment', 1, ?, 2, ?, ?)`,
).run(timestamp, timestamp, timestamp);
db.prepare(
`INSERT INTO imm_subtitle_lines (
session_id, video_id, anime_id, line_index, text, CREATED_DATE, LAST_UPDATE_DATE
) VALUES (1, 1, 1, 1, 'stale line', ?, ?)`,
).run(timestamp, timestamp);
repairJellyfinStreamVideoLinks(db);
const video = db.prepare('SELECT anime_id FROM imm_videos WHERE video_id = 1').get() as {
anime_id: number | null;
};
const line = db.prepare('SELECT anime_id FROM imm_subtitle_lines WHERE video_id = 1').get() as {
anime_id: number | null;
};
assert.equal(video.anime_id, null);
assert.equal(line.anime_id, null);
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
test('applies configurable queue, flush, and retention policy', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
@@ -4232,6 +4292,23 @@ printf '%s\n' '${ytDlpOutput}'
nowMs,
nowMs,
);
privateApi.db
.prepare(
`INSERT INTO imm_anime (
anime_id, normalized_title_key, canonical_title, CREATED_DATE, LAST_UPDATE_DATE
) VALUES (1, 'manual backfill collection', 'Manual Backfill Collection', ?, ?)`,
)
.run(nowMs, nowMs);
privateApi.db
.prepare(
`UPDATE imm_videos
SET anime_id = 1,
anime_assignment_locked = 1,
parsed_title = 'Manual Backfill Collection',
parser_source = 'manual-test'
WHERE video_id = 1`,
)
.run();
privateApi.db
.prepare(
`
@@ -4286,6 +4363,15 @@ printf '%s\n' '${ytDlpOutput}'
after[0]?.channelThumbnailUrl,
'https://yt3.googleusercontent.com/backfill-avatar=s88',
);
const lockedVideo = privateApi.db
.prepare(
`SELECT anime_id AS animeId, parsed_title AS parsedTitle
FROM imm_videos
WHERE video_id = 1`,
)
.get() as { animeId: number | null; parsedTitle: string | null };
assert.equal(lockedVideo.animeId, 1);
assert.equal(lockedVideo.parsedTitle, 'Manual Backfill Collection');
} finally {
process.env.PATH = originalPath;
tracker?.destroy();
@@ -4296,7 +4382,7 @@ printf '%s\n' '${ytDlpOutput}'
}
});
test('getAnimeLibrary lazily relinks youtube rows to channel groupings', async () => {
test('getAnimeLibrary lazily relinks unlocked youtube rows without moving manual assignments', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
@@ -4320,6 +4406,7 @@ test('getAnimeLibrary lazily relinks youtube rows to channel groupings', async (
INSERT INTO imm_videos (
video_id,
anime_id,
anime_assignment_locked,
video_key,
canonical_title,
parsed_title,
@@ -4345,6 +4432,7 @@ test('getAnimeLibrary lazily relinks youtube rows to channel groupings', async (
(
1,
1,
0,
'remote:https://www.youtube.com/watch?v=first',
'watch?v first',
'watch?v first',
@@ -4370,6 +4458,7 @@ test('getAnimeLibrary lazily relinks youtube rows to channel groupings', async (
(
2,
2,
1,
'remote:https://www.youtube.com/watch?v=second',
'watch?v second',
'watch?v second',
@@ -4541,7 +4630,7 @@ test('getAnimeLibrary lazily relinks youtube rows to channel groupings', async (
const sharedRows = rows.filter((row) => row.canonicalTitle === 'Shared Channel');
assert.equal(sharedRows.length, 1);
assert.equal(sharedRows[0]?.episodeCount, 2);
assert.equal(sharedRows[0]?.episodeCount, 1);
const relinked = privateApi.db
.prepare(
@@ -4555,8 +4644,17 @@ test('getAnimeLibrary lazily relinks youtube rows to channel groupings', async (
)
.all() as Array<{ canonicalTitle: string; total: number }>;
assert.equal(relinked[0]?.canonicalTitle, 'Shared Channel');
assert.equal(relinked[0]?.total, 2);
assert.equal(relinked.find((row) => row.canonicalTitle === 'Shared Channel')?.total, 1);
assert.equal(relinked.find((row) => row.canonicalTitle === 'watch?v second')?.total, 1);
const lockedVideo = privateApi.db
.prepare(
`SELECT anime_id AS animeId, parsed_title AS parsedTitle
FROM imm_videos
WHERE video_id = 2`,
)
.get() as { animeId: number | null; parsedTitle: string | null };
assert.equal(lockedVideo.animeId, 2);
assert.equal(lockedVideo.parsedTitle, 'watch?v second');
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
@@ -1318,6 +1318,7 @@ export class ImmersionTrackerService {
LEFT JOIN imm_lifetime_media lm ON lm.video_id = v.video_id
WHERE
v.source_type = ?
AND v.anime_assignment_locked = 0
AND v.source_url IS NOT NULL
AND (
LOWER(v.source_url) LIKE 'https://www.youtube.com/%'
@@ -406,15 +406,13 @@ export function repairJellyfinStreamVideoLinks(db: DatabaseSync): JellyfinLinkRe
currentTimestamp,
candidate.video_id,
);
if (assignmentAnimeId !== null) {
db.prepare(
`
UPDATE imm_subtitle_lines
SET anime_id = ?, LAST_UPDATE_DATE = ?
WHERE video_id = ?
`,
).run(assignmentAnimeId, currentTimestamp, candidate.video_id);
}
db.prepare(
`
UPDATE imm_subtitle_lines
SET anime_id = ?, LAST_UPDATE_DATE = ?
WHERE video_id = ?
`,
).run(assignmentAnimeId, currentTimestamp, candidate.video_id);
summary.repaired += 1;
}
summary.repaired += repairLeakedJellyfinAnimeTitles(db, currentTimestamp);
@@ -1366,6 +1366,70 @@ test('youtube videos can be regrouped under a shared channel anime identity', ()
}
});
test('youtube channel relinking preserves a locked manual assignment', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
try {
ensureSchema(db);
const videoId = getOrCreateVideoRecord(db, 'remote:https://www.youtube.com/watch?v=locked', {
canonicalTitle: 'Locked Video',
sourcePath: null,
sourceUrl: 'https://www.youtube.com/watch?v=locked',
sourceType: SOURCE_TYPE_REMOTE,
});
const manualAnimeId = getOrCreateAnimeRecord(db, {
parsedTitle: 'Manual Collection',
canonicalTitle: 'Manual Collection',
anilistId: null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
metadataJson: null,
});
linkVideoToAnimeRecord(db, videoId, {
animeId: manualAnimeId,
parsedBasename: null,
parsedTitle: 'Manual Collection',
parsedSeason: null,
parsedEpisode: null,
parserSource: 'manual-test',
parserConfidence: 1,
parseMetadataJson: null,
});
db.prepare('UPDATE imm_videos SET anime_assignment_locked = 1 WHERE video_id = ?').run(videoId);
const linkedAnimeId = linkYoutubeVideoToAnimeRecord(db, videoId, {
youtubeVideoId: 'locked',
videoUrl: 'https://www.youtube.com/watch?v=locked',
videoTitle: 'Locked Video',
videoThumbnailUrl: null,
channelId: 'UC-locked',
channelName: 'Automatic Channel',
channelUrl: null,
channelThumbnailUrl: null,
uploaderId: null,
uploaderUrl: null,
description: null,
metadataJson: null,
});
assert.equal(linkedAnimeId, manualAnimeId);
const video = db
.prepare('SELECT anime_id, parsed_title FROM imm_videos WHERE video_id = ?')
.get(videoId) as { anime_id: number | null; parsed_title: string | null };
assert.equal(video.anime_id, manualAnimeId);
assert.equal(video.parsed_title, 'Manual Collection');
const automaticAnime = db
.prepare(`SELECT anime_id FROM imm_anime WHERE canonical_title = 'Automatic Channel'`)
.get();
assert.equal(automaticAnime, undefined);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('start/finalize session updates ended_at and status', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
+14 -3
View File
@@ -677,8 +677,11 @@ export function linkVideoToAnimeRecord(
);
}
export function getManualAnimeAssignment(db: DatabaseSync, videoId: number): number | null {
const row = db
function getLockedAnimeAssignment(
db: DatabaseSync,
videoId: number,
): { animeId: number | null } | null {
return db
.prepare(
`
SELECT anime_id AS animeId
@@ -688,7 +691,10 @@ export function getManualAnimeAssignment(db: DatabaseSync, videoId: number): num
`,
)
.get(videoId) as { animeId: number | null } | null;
return row?.animeId ?? null;
}
export function getManualAnimeAssignment(db: DatabaseSync, videoId: number): number | null {
return getLockedAnimeAssignment(db, videoId)?.animeId ?? null;
}
/**
@@ -743,6 +749,11 @@ export function linkYoutubeVideoToAnimeRecord(
videoId: number,
metadata: YoutubeVideoMetadata,
): number | null {
const lockedAssignment = getLockedAnimeAssignment(db, videoId);
if (lockedAssignment) {
return lockedAssignment.animeId;
}
const identity = buildYoutubeChannelAnimeIdentity(metadata);
if (!identity) {
return null;