mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-14 13:55:55 -07:00
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:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user