mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-14 13:55:55 -07:00
fix(stats): preserve manual episode assignments across reparsing
This commit is contained in:
@@ -397,7 +397,14 @@ export async function resolveAnilistSeasonMedia(
|
||||
deps.logInfo?.(
|
||||
`[anilist] season ${season} of "${searchTitle}" resolved via sequel chain: ${displayTitle(viaChain, searchTitle)} (${viaChain.id})`,
|
||||
);
|
||||
return toResolution(viaChain, searchTitle, season, 'sequel-chain', true, exactMatchFor(viaChain));
|
||||
return toResolution(
|
||||
viaChain,
|
||||
searchTitle,
|
||||
season,
|
||||
'sequel-chain',
|
||||
true,
|
||||
exactMatchFor(viaChain),
|
||||
);
|
||||
}
|
||||
|
||||
const viaAirOrder = pickByAirOrder(anchor, season, media);
|
||||
|
||||
@@ -2132,6 +2132,78 @@ test('handleMediaChange reuses the same provisional anime row across matching fi
|
||||
}
|
||||
});
|
||||
|
||||
test('local parsing reuses a unique compatible manual assignment from the same directory', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
|
||||
try {
|
||||
const Ctor = await loadTrackerCtor();
|
||||
tracker = new Ctor({ dbPath });
|
||||
const anchorPath = '/tmp/grouped/Incorrect Name S01E01.mkv';
|
||||
tracker.handleMediaChange(anchorPath, 'Episode 1');
|
||||
await waitForPendingAnimeMetadata(tracker);
|
||||
|
||||
const privateApi = tracker as unknown as {
|
||||
db: DatabaseSync;
|
||||
sessionState: { videoId: number } | null;
|
||||
};
|
||||
const anchorVideoId = privateApi.sessionState?.videoId;
|
||||
assert.ok(anchorVideoId);
|
||||
tracker.handleMediaChange(null, null);
|
||||
const timestamp = toDbTimestamp(trackerNowMs());
|
||||
const target = privateApi.db
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO imm_anime (
|
||||
normalized_title_key,
|
||||
canonical_title,
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE
|
||||
) VALUES ('correct show season 1', 'Correct Show Season 1', ?, ?)
|
||||
RETURNING anime_id AS animeId
|
||||
`,
|
||||
)
|
||||
.get(timestamp, timestamp) as { animeId: number };
|
||||
await tracker.moveVideoToAnime(anchorVideoId, target.animeId);
|
||||
|
||||
tracker.handleMediaChange(anchorPath, 'Episode 1');
|
||||
await waitForPendingAnimeMetadata(tracker);
|
||||
tracker.handleMediaChange('/tmp/grouped/Another Wrong Name S01E02.mkv', 'Episode 2');
|
||||
await waitForPendingAnimeMetadata(tracker);
|
||||
tracker.handleMediaChange('/tmp/grouped/Another Wrong Name S02E01.mkv', 'Episode 1');
|
||||
await waitForPendingAnimeMetadata(tracker);
|
||||
|
||||
const rows = privateApi.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT source_path AS sourcePath, anime_id AS animeId, anime_assignment_locked AS locked
|
||||
FROM imm_videos
|
||||
WHERE source_path LIKE '/tmp/grouped/%'
|
||||
ORDER BY source_path
|
||||
`,
|
||||
)
|
||||
.all() as Array<{ sourcePath: string; animeId: number; locked: number }>;
|
||||
const assignments = new Map(rows.map((row) => [row.sourcePath, row]));
|
||||
assert.deepEqual(assignments.get(anchorPath), {
|
||||
sourcePath: anchorPath,
|
||||
animeId: target.animeId,
|
||||
locked: 1,
|
||||
});
|
||||
assert.deepEqual(assignments.get('/tmp/grouped/Another Wrong Name S01E02.mkv'), {
|
||||
sourcePath: '/tmp/grouped/Another Wrong Name S01E02.mkv',
|
||||
animeId: target.animeId,
|
||||
locked: 0,
|
||||
});
|
||||
assert.notEqual(
|
||||
assignments.get('/tmp/grouped/Another Wrong Name S02E01.mkv')?.animeId,
|
||||
target.animeId,
|
||||
);
|
||||
} finally {
|
||||
tracker?.destroy();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('handleMediaChange splits matching parsed titles across distinct seasons', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
@@ -2618,6 +2690,67 @@ test('Jellyfin playback metadata links stream videos to existing series title',
|
||||
}
|
||||
});
|
||||
|
||||
test('Jellyfin metadata refresh preserves a manual episode assignment', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
|
||||
try {
|
||||
const Ctor = await loadTrackerCtor();
|
||||
tracker = new Ctor({ dbPath });
|
||||
const metadata = {
|
||||
mediaPath: 'http://jellyfin.local/Videos/item-locked/stream?api_key=token',
|
||||
displayTitle: 'Parsed Show S01E01',
|
||||
itemTitle: 'Episode 1',
|
||||
seriesTitle: 'Parsed Show',
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 1,
|
||||
itemId: 'item-locked',
|
||||
};
|
||||
tracker.recordJellyfinPlaybackMetadata(metadata);
|
||||
|
||||
const privateApi = tracker as unknown as { db: DatabaseSync };
|
||||
const video = privateApi.db.prepare('SELECT video_id AS videoId FROM imm_videos').get() as {
|
||||
videoId: number;
|
||||
};
|
||||
const timestamp = toDbTimestamp(trackerNowMs());
|
||||
const target = privateApi.db
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO imm_anime (
|
||||
normalized_title_key,
|
||||
canonical_title,
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE
|
||||
) VALUES ('correct show', 'Correct Show', ?, ?)
|
||||
RETURNING anime_id AS animeId
|
||||
`,
|
||||
)
|
||||
.get(timestamp, timestamp) as { animeId: number };
|
||||
|
||||
await tracker.moveVideoToAnime(video.videoId, target.animeId);
|
||||
tracker.recordJellyfinPlaybackMetadata(metadata);
|
||||
|
||||
const assignment = privateApi.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT anime_id AS animeId, anime_assignment_locked AS locked
|
||||
FROM imm_videos
|
||||
WHERE video_id = ?
|
||||
`,
|
||||
)
|
||||
.get(video.videoId) as { animeId: number; locked: number };
|
||||
assert.equal(assignment.animeId, target.animeId);
|
||||
assert.equal(assignment.locked, 1);
|
||||
const animeCount = privateApi.db.prepare('SELECT COUNT(*) AS count FROM imm_anime').get() as {
|
||||
count: number;
|
||||
};
|
||||
assert.equal(animeCount.count, 1);
|
||||
} finally {
|
||||
tracker?.destroy();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('startup repairs existing Jellyfin stream video links to metadata rows', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
applyPragmas,
|
||||
createTrackerPreparedStatements,
|
||||
ensureSchema,
|
||||
findManualDirectoryAnimeAssignment,
|
||||
getManualAnimeAssignment,
|
||||
executeQueuedWrite,
|
||||
getOrCreateAnimeRecord,
|
||||
getOrCreateVideoRecord,
|
||||
@@ -1421,16 +1423,18 @@ export class ImmersionTrackerService {
|
||||
seasonNumber,
|
||||
episodeNumber,
|
||||
});
|
||||
const animeId = getOrCreateAnimeRecord(this.db, {
|
||||
parsedTitle: libraryTitle,
|
||||
canonicalTitle: libraryTitle,
|
||||
seasonScope: seasonNumber,
|
||||
anilistId: null,
|
||||
titleRomaji: null,
|
||||
titleEnglish: null,
|
||||
titleNative: null,
|
||||
metadataJson,
|
||||
});
|
||||
const animeId =
|
||||
getManualAnimeAssignment(this.db, videoId) ??
|
||||
getOrCreateAnimeRecord(this.db, {
|
||||
parsedTitle: libraryTitle,
|
||||
canonicalTitle: libraryTitle,
|
||||
seasonScope: seasonNumber,
|
||||
anilistId: null,
|
||||
titleRomaji: null,
|
||||
titleEnglish: null,
|
||||
titleNative: null,
|
||||
metadataJson,
|
||||
});
|
||||
linkVideoToAnimeRecord(this.db, videoId, {
|
||||
animeId,
|
||||
parsedBasename: null,
|
||||
@@ -2067,16 +2071,21 @@ export class ImmersionTrackerService {
|
||||
return;
|
||||
}
|
||||
|
||||
const animeId = getOrCreateAnimeRecord(this.db, {
|
||||
parsedTitle: parsed.parsedTitle,
|
||||
canonicalTitle: parsed.parsedTitle,
|
||||
seasonScope: parsed.parsedSeason,
|
||||
anilistId: null,
|
||||
titleRomaji: null,
|
||||
titleEnglish: null,
|
||||
titleNative: null,
|
||||
metadataJson: parsed.parseMetadataJson,
|
||||
});
|
||||
const animeId =
|
||||
getManualAnimeAssignment(this.db, videoId) ??
|
||||
(mediaPath && !isRemoteSource(mediaPath)
|
||||
? findManualDirectoryAnimeAssignment(this.db, videoId, mediaPath, parsed.parsedSeason)
|
||||
: null) ??
|
||||
getOrCreateAnimeRecord(this.db, {
|
||||
parsedTitle: parsed.parsedTitle,
|
||||
canonicalTitle: parsed.parsedTitle,
|
||||
seasonScope: parsed.parsedSeason,
|
||||
anilistId: null,
|
||||
titleRomaji: null,
|
||||
titleEnglish: null,
|
||||
titleNative: null,
|
||||
metadataJson: parsed.parseMetadataJson,
|
||||
});
|
||||
linkVideoToAnimeRecord(this.db, videoId, {
|
||||
animeId,
|
||||
parsedBasename: parsed.parsedBasename,
|
||||
|
||||
@@ -5,7 +5,14 @@ import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { Database } from '../sqlite.js';
|
||||
import type { DatabaseSync } from '../sqlite.js';
|
||||
import { applyPragmas, ensureSchema, getOrCreateAnimeRecord } from '../storage.js';
|
||||
import {
|
||||
applyPragmas,
|
||||
ensureSchema,
|
||||
findManualDirectoryAnimeAssignment,
|
||||
getManualAnimeAssignment,
|
||||
getOrCreateAnimeRecord,
|
||||
linkVideoToAnimeRecord,
|
||||
} from '../storage.js';
|
||||
import { mergeAnimeRecords, moveVideoToAnime } from '../anime-merge.js';
|
||||
import {
|
||||
dismissAnimeMergeRecommendation,
|
||||
@@ -140,6 +147,14 @@ function videoAnimeId(db: DatabaseSync, videoId: number): number | null {
|
||||
).id;
|
||||
}
|
||||
|
||||
function assignmentLocked(db: DatabaseSync, videoId: number): number {
|
||||
return (
|
||||
db
|
||||
.prepare('SELECT anime_assignment_locked AS locked FROM imm_videos WHERE video_id = ?')
|
||||
.get(videoId) as { locked: number }
|
||||
).locked;
|
||||
}
|
||||
|
||||
function lineAnimeIds(db: DatabaseSync, animeId: number): number {
|
||||
return Number(
|
||||
(
|
||||
@@ -216,7 +231,10 @@ test('merge and move preserve lifetime history whose raw sessions were pruned',
|
||||
assert.equal(survivor.activeMs, 7000);
|
||||
assert.equal(survivor.cards, 9);
|
||||
assert.equal(survivor.episodes, 3);
|
||||
assert.equal(db.prepare('SELECT 1 FROM imm_lifetime_anime WHERE anime_id = 3').get(), undefined);
|
||||
assert.equal(
|
||||
db.prepare('SELECT 1 FROM imm_lifetime_anime WHERE anime_id = 3').get(),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -348,6 +366,8 @@ test('moveVideoToAnime moves one episode and prunes the emptied entry', () => {
|
||||
assert.equal(summary.removedPreviousAnime, true);
|
||||
assert.deepEqual(animeIds(db), [1]);
|
||||
assert.equal(videoAnimeId(db, 2), 1);
|
||||
assert.equal(assignmentLocked(db, 2), 1);
|
||||
assert.equal(getManualAnimeAssignment(db, 2), 1);
|
||||
assert.equal(lineAnimeIds(db, 1), 2);
|
||||
const lifetime = db
|
||||
.prepare('SELECT total_active_ms AS activeMs FROM imm_lifetime_anime WHERE anime_id = 1')
|
||||
@@ -374,6 +394,76 @@ test('moveVideoToAnime is a no-op when the episode is already in the target entr
|
||||
assert.equal(summary.removedPreviousAnime, false);
|
||||
assert.deepEqual(animeIds(db), [1]);
|
||||
assert.equal(videoAnimeId(db, 1), 1);
|
||||
assert.equal(assignmentLocked(db, 1), 1);
|
||||
});
|
||||
});
|
||||
|
||||
test('automatic metadata cannot overwrite a manual episode assignment', () => {
|
||||
withDb((db) => {
|
||||
insertAnime(db, { animeId: 1, key: 'show', title: 'Show' });
|
||||
insertAnime(db, { animeId: 2, key: 'stray', title: 'Stray' });
|
||||
insertAnime(db, { animeId: 3, key: 'parser result', title: 'Parser Result' });
|
||||
insertEpisode(db, { videoId: 1, animeId: 2, season: 1 });
|
||||
|
||||
moveVideoToAnime(db, 1, 1);
|
||||
linkVideoToAnimeRecord(db, 1, {
|
||||
animeId: 3,
|
||||
parsedBasename: 'Parser Result S01E01.mkv',
|
||||
parsedTitle: 'Parser Result',
|
||||
parsedSeason: 1,
|
||||
parsedEpisode: 1,
|
||||
parserSource: 'guessit',
|
||||
parserConfidence: 1,
|
||||
parseMetadataJson: null,
|
||||
});
|
||||
|
||||
assert.equal(videoAnimeId(db, 1), 1);
|
||||
assert.equal(getManualAnimeAssignment(db, 1), 1);
|
||||
const parsedTitle = db
|
||||
.prepare('SELECT parsed_title AS parsedTitle FROM imm_videos WHERE video_id = 1')
|
||||
.get() as { parsedTitle: string | null };
|
||||
assert.equal(parsedTitle.parsedTitle, 'Parser Result');
|
||||
});
|
||||
});
|
||||
|
||||
test('directory grouping requires one season-compatible manual destination', () => {
|
||||
withDb((db) => {
|
||||
insertAnime(db, { animeId: 1, key: 'show', title: 'Show' });
|
||||
insertAnime(db, { animeId: 2, key: 'stray', title: 'Stray' });
|
||||
insertAnime(db, { animeId: 3, key: 'other', title: 'Other' });
|
||||
insertEpisode(db, { videoId: 1, animeId: 2, season: 1 });
|
||||
insertEpisode(db, { videoId: 2, animeId: 3, season: 1 });
|
||||
insertEpisode(db, { videoId: 3, animeId: 3, season: 1 });
|
||||
db.prepare('UPDATE imm_videos SET source_path = ? WHERE video_id = ?').run(
|
||||
'/library/show/Show S01E01.mkv',
|
||||
1,
|
||||
);
|
||||
db.prepare('UPDATE imm_videos SET source_path = ? WHERE video_id = ?').run(
|
||||
'/library/show/Stray S01E02.mkv',
|
||||
2,
|
||||
);
|
||||
db.prepare('UPDATE imm_videos SET source_path = ? WHERE video_id = ?').run(
|
||||
'/library/show/Other S01E03.mkv',
|
||||
3,
|
||||
);
|
||||
|
||||
moveVideoToAnime(db, 1, 1);
|
||||
|
||||
assert.equal(findManualDirectoryAnimeAssignment(db, 2, '/library/show/Stray S01E02.mkv', 1), 1);
|
||||
assert.equal(
|
||||
findManualDirectoryAnimeAssignment(db, 2, '/library/show/Stray S02E02.mkv', 2),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
findManualDirectoryAnimeAssignment(db, 2, '/library/other/Stray S01E02.mkv', 1),
|
||||
null,
|
||||
);
|
||||
|
||||
moveVideoToAnime(db, 3, 3);
|
||||
assert.equal(
|
||||
findManualDirectoryAnimeAssignment(db, 2, '/library/show/Stray S01E02.mkv', 1),
|
||||
null,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -584,6 +674,24 @@ test('resolveAnimeAnilistConflict falls back to season redistribution for multi-
|
||||
});
|
||||
});
|
||||
|
||||
test('season redistribution leaves manually assigned episodes in place', () => {
|
||||
withDb((db) => {
|
||||
insertAnime(db, { animeId: 1, key: 'show', title: 'Show', anilistId: 163132 });
|
||||
insertAnime(db, { animeId: 2, key: 'show season 1', title: 'Show Season 1' });
|
||||
insertEpisode(db, { videoId: 1, animeId: 1, season: 1 });
|
||||
insertEpisode(db, { videoId: 2, animeId: 1, season: 2 });
|
||||
insertEpisode(db, { videoId: 3, animeId: 2, season: 1 });
|
||||
moveVideoToAnime(db, 1, 1);
|
||||
|
||||
const summary = resolveAnimeAnilistConflict(db, 2, 163132);
|
||||
|
||||
assert.equal(videoAnimeId(db, 1), 1);
|
||||
assert.equal(assignmentLocked(db, 1), 1);
|
||||
assert.notEqual(videoAnimeId(db, 2), 1);
|
||||
assert.equal(summary.movedVideos, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveAnimeAnilistConflict leaves explicit incompatible seasons and assignments unchanged', () => {
|
||||
withDb((db) => {
|
||||
insertAnime(db, {
|
||||
|
||||
@@ -263,15 +263,18 @@ export function moveVideoToAnime(
|
||||
|
||||
const previousAnimeId = videoRow.animeId;
|
||||
if (previousAnimeId === targetAnimeId) {
|
||||
db.prepare(
|
||||
'UPDATE imm_videos SET anime_assignment_locked = 1, LAST_UPDATE_DATE = ? WHERE video_id = ?',
|
||||
).run(toDbTimestamp(nowMs()), videoId);
|
||||
return { targetAnimeId, previousAnimeId, removedPreviousAnime: false };
|
||||
}
|
||||
|
||||
const updatedAt = toDbTimestamp(nowMs());
|
||||
db.prepare('UPDATE imm_videos SET anime_id = ?, LAST_UPDATE_DATE = ? WHERE video_id = ?').run(
|
||||
targetAnimeId,
|
||||
updatedAt,
|
||||
videoId,
|
||||
);
|
||||
db.prepare(
|
||||
`UPDATE imm_videos
|
||||
SET anime_id = ?, anime_assignment_locked = 1, LAST_UPDATE_DATE = ?
|
||||
WHERE video_id = ?`,
|
||||
).run(targetAnimeId, updatedAt, videoId);
|
||||
db.prepare(
|
||||
'UPDATE imm_subtitle_lines SET anime_id = ?, LAST_UPDATE_DATE = ? WHERE video_id = ?',
|
||||
).run(targetAnimeId, updatedAt, videoId);
|
||||
|
||||
@@ -61,6 +61,7 @@ interface ParsedVideoRow {
|
||||
video_id: number;
|
||||
parsed_title: string | null;
|
||||
parsed_season: number | null;
|
||||
anime_assignment_locked: number;
|
||||
}
|
||||
|
||||
interface RedistributeOptions {
|
||||
@@ -137,7 +138,7 @@ function getParsedVideos(db: DatabaseSync, animeId: number): ParsedVideoRow[] {
|
||||
return db
|
||||
.prepare(
|
||||
`
|
||||
SELECT video_id, parsed_title, parsed_season
|
||||
SELECT video_id, parsed_title, parsed_season, anime_assignment_locked
|
||||
FROM imm_videos
|
||||
WHERE anime_id = ?
|
||||
ORDER BY video_id ASC
|
||||
@@ -231,6 +232,9 @@ function redistributeAnimeRowByParsedSeasonsInTransaction(
|
||||
const targetBySeason = new Map<number, number>();
|
||||
|
||||
for (const video of videos) {
|
||||
if (video.anime_assignment_locked === 1) {
|
||||
continue;
|
||||
}
|
||||
const parsedTitle = video.parsed_title?.trim();
|
||||
const season = normalizeSeason(video.parsed_season);
|
||||
if (!parsedTitle || season === null) {
|
||||
|
||||
@@ -8,6 +8,8 @@ import type { JellyfinLinkRepairSummary } from './types';
|
||||
type LegacyJellyfinVideoRow = {
|
||||
video_id: number;
|
||||
video_key: string;
|
||||
anime_id: number | null;
|
||||
anime_assignment_locked: number;
|
||||
source_url: string | null;
|
||||
canonical_title: string;
|
||||
};
|
||||
@@ -15,6 +17,7 @@ type LegacyJellyfinVideoRow = {
|
||||
type JellyfinTargetVideoRow = {
|
||||
video_id: number;
|
||||
anime_id: number | null;
|
||||
anime_assignment_locked: number;
|
||||
canonical_title: string;
|
||||
parsed_basename: string | null;
|
||||
parsed_title: string | null;
|
||||
@@ -258,7 +261,13 @@ export function repairJellyfinStreamVideoLinks(db: DatabaseSync): JellyfinLinkRe
|
||||
const candidates = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT video_id, video_key, source_url, canonical_title
|
||||
SELECT
|
||||
video_id,
|
||||
video_key,
|
||||
anime_id,
|
||||
anime_assignment_locked,
|
||||
source_url,
|
||||
canonical_title
|
||||
FROM imm_videos
|
||||
WHERE source_type = 2
|
||||
AND (
|
||||
@@ -310,6 +319,7 @@ export function repairJellyfinStreamVideoLinks(db: DatabaseSync): JellyfinLinkRe
|
||||
SELECT
|
||||
video_id,
|
||||
anime_id,
|
||||
anime_assignment_locked,
|
||||
canonical_title,
|
||||
parsed_basename,
|
||||
parsed_title,
|
||||
@@ -357,12 +367,17 @@ export function repairJellyfinStreamVideoLinks(db: DatabaseSync): JellyfinLinkRe
|
||||
continue;
|
||||
}
|
||||
|
||||
const assignmentAnimeId =
|
||||
candidate.anime_assignment_locked === 1 ? candidate.anime_id : target.anime_id;
|
||||
const assignmentLocked =
|
||||
candidate.anime_assignment_locked === 1 || target.anime_assignment_locked === 1 ? 1 : 0;
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE imm_videos
|
||||
SET
|
||||
video_key = ?,
|
||||
anime_id = ?,
|
||||
anime_assignment_locked = ?,
|
||||
canonical_title = ?,
|
||||
source_url = ?,
|
||||
parsed_basename = ?,
|
||||
@@ -377,7 +392,8 @@ export function repairJellyfinStreamVideoLinks(db: DatabaseSync): JellyfinLinkRe
|
||||
`,
|
||||
).run(
|
||||
sanitizedVideoKey,
|
||||
target.anime_id,
|
||||
assignmentAnimeId,
|
||||
assignmentLocked,
|
||||
target.canonical_title,
|
||||
statsUrl,
|
||||
target.parsed_basename,
|
||||
@@ -390,14 +406,14 @@ export function repairJellyfinStreamVideoLinks(db: DatabaseSync): JellyfinLinkRe
|
||||
currentTimestamp,
|
||||
candidate.video_id,
|
||||
);
|
||||
if (target.anime_id !== null) {
|
||||
if (assignmentAnimeId !== null) {
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE imm_subtitle_lines
|
||||
SET anime_id = ?, LAST_UPDATE_DATE = ?
|
||||
WHERE video_id = ?
|
||||
`,
|
||||
).run(target.anime_id, currentTimestamp, candidate.video_id);
|
||||
).run(assignmentAnimeId, currentTimestamp, candidate.video_id);
|
||||
}
|
||||
summary.repaired += 1;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from './storage';
|
||||
import {
|
||||
EVENT_SUBTITLE_LINE,
|
||||
SCHEMA_VERSION,
|
||||
SESSION_STATUS_ENDED,
|
||||
SOURCE_TYPE_LOCAL,
|
||||
SOURCE_TYPE_REMOTE,
|
||||
@@ -132,6 +133,7 @@ test('ensureSchema creates immersion core tables', () => {
|
||||
assert.ok(videoColumns.has('parser_source'));
|
||||
assert.ok(videoColumns.has('parser_confidence'));
|
||||
assert.ok(videoColumns.has('parse_metadata_json'));
|
||||
assert.ok(videoColumns.has('anime_assignment_locked'));
|
||||
|
||||
const mediaArtColumns = new Set(
|
||||
(
|
||||
@@ -155,6 +157,33 @@ test('ensureSchema creates immersion core tables', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('ensureSchema adds manual assignment locks when upgrading the previous schema', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
|
||||
try {
|
||||
ensureSchema(db);
|
||||
db.exec('ALTER TABLE imm_videos DROP COLUMN anime_assignment_locked');
|
||||
db.prepare('UPDATE imm_schema_version SET schema_version = ?').run(SCHEMA_VERSION - 1);
|
||||
|
||||
ensureSchema(db);
|
||||
|
||||
const columns = new Set(
|
||||
(db.prepare('PRAGMA table_info(imm_videos)').all() as Array<{ name: string }>).map(
|
||||
(row) => row.name,
|
||||
),
|
||||
);
|
||||
assert.ok(columns.has('anime_assignment_locked'));
|
||||
const version = db
|
||||
.prepare('SELECT MAX(schema_version) AS version FROM imm_schema_version')
|
||||
.get() as { version: number };
|
||||
assert.equal(version.version, SCHEMA_VERSION);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('stats excluded words are replaced and read from sqlite storage', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
@@ -807,6 +836,7 @@ test('ensureSchema migrates legacy videos and backfills anime metadata from file
|
||||
assert.ok(videoColumns.has('parser_source'));
|
||||
assert.ok(videoColumns.has('parser_confidence'));
|
||||
assert.ok(videoColumns.has('parse_metadata_json'));
|
||||
assert.ok(videoColumns.has('anime_assignment_locked'));
|
||||
|
||||
const animeRows = db
|
||||
.prepare('SELECT canonical_title FROM imm_anime ORDER BY canonical_title')
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import { parseMediaInfo } from '../../../jimaku/utils';
|
||||
import { normalizeTitleIdentity } from '../../utils/title-normalization';
|
||||
import type { DatabaseSync } from './sqlite';
|
||||
@@ -648,7 +649,10 @@ export function linkVideoToAnimeRecord(
|
||||
`
|
||||
UPDATE imm_videos
|
||||
SET
|
||||
anime_id = ?,
|
||||
anime_id = CASE
|
||||
WHEN anime_assignment_locked = 1 THEN anime_id
|
||||
ELSE ?
|
||||
END,
|
||||
parsed_basename = ?,
|
||||
parsed_title = ?,
|
||||
parsed_season = ?,
|
||||
@@ -673,6 +677,67 @@ export function linkVideoToAnimeRecord(
|
||||
);
|
||||
}
|
||||
|
||||
export function getManualAnimeAssignment(db: DatabaseSync, videoId: number): number | null {
|
||||
const row = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT anime_id AS animeId
|
||||
FROM imm_videos
|
||||
WHERE video_id = ?
|
||||
AND anime_assignment_locked = 1
|
||||
`,
|
||||
)
|
||||
.get(videoId) as { animeId: number | null } | null;
|
||||
return row?.animeId ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A manual correction in the same folder is a useful grouping hint, but only
|
||||
* when every season-compatible correction agrees on the destination.
|
||||
*/
|
||||
export function findManualDirectoryAnimeAssignment(
|
||||
db: DatabaseSync,
|
||||
videoId: number,
|
||||
mediaPath: string,
|
||||
parsedSeason: number | null,
|
||||
): number | null {
|
||||
const directory = path.dirname(path.resolve(mediaPath));
|
||||
const rows = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
anime_id AS animeId,
|
||||
source_path AS sourcePath,
|
||||
parsed_season AS parsedSeason
|
||||
FROM imm_videos
|
||||
WHERE video_id != ?
|
||||
AND anime_assignment_locked = 1
|
||||
AND anime_id IS NOT NULL
|
||||
AND source_path IS NOT NULL
|
||||
`,
|
||||
)
|
||||
.all(videoId) as Array<{
|
||||
animeId: number;
|
||||
sourcePath: string;
|
||||
parsedSeason: number | null;
|
||||
}>;
|
||||
|
||||
const candidates = new Set<number>();
|
||||
for (const row of rows) {
|
||||
if (path.dirname(path.resolve(row.sourcePath)) !== directory) {
|
||||
continue;
|
||||
}
|
||||
if (parsedSeason !== null && row.parsedSeason !== null && parsedSeason !== row.parsedSeason) {
|
||||
continue;
|
||||
}
|
||||
candidates.add(row.animeId);
|
||||
if (candidates.size > 1) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return candidates.values().next().value ?? null;
|
||||
}
|
||||
|
||||
export function linkYoutubeVideoToAnimeRecord(
|
||||
db: DatabaseSync,
|
||||
videoId: number,
|
||||
@@ -817,6 +882,7 @@ export function ensureSchema(db: DatabaseSync): void {
|
||||
parser_source TEXT,
|
||||
parser_confidence REAL,
|
||||
parse_metadata_json TEXT,
|
||||
anime_assignment_locked INTEGER NOT NULL DEFAULT 0 CHECK(anime_assignment_locked IN (0, 1)),
|
||||
watched INTEGER NOT NULL DEFAULT 0,
|
||||
duration_ms INTEGER NOT NULL CHECK(duration_ms>=0),
|
||||
file_size_bytes INTEGER CHECK(file_size_bytes>=0),
|
||||
@@ -830,6 +896,12 @@ export function ensureSchema(db: DatabaseSync): void {
|
||||
FOREIGN KEY(anime_id) REFERENCES imm_anime(anime_id) ON DELETE SET NULL
|
||||
);
|
||||
`);
|
||||
addColumnIfMissing(
|
||||
db,
|
||||
'imm_videos',
|
||||
'anime_assignment_locked',
|
||||
'INTEGER NOT NULL DEFAULT 0 CHECK(anime_assignment_locked IN (0, 1))',
|
||||
);
|
||||
ensureAnimeMergeTables(db);
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS imm_sessions(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const SCHEMA_VERSION = 20;
|
||||
export const SCHEMA_VERSION = 21;
|
||||
export const DEFAULT_QUEUE_CAP = 1_000;
|
||||
export const DEFAULT_BATCH_SIZE = 25;
|
||||
export const DEFAULT_FLUSH_INTERVAL_MS = 500;
|
||||
|
||||
@@ -28,6 +28,7 @@ const VIDEO_COPY_COLUMNS = [
|
||||
'parser_source',
|
||||
'parser_confidence',
|
||||
'parse_metadata_json',
|
||||
'anime_assignment_locked',
|
||||
'watched',
|
||||
'duration_ms',
|
||||
'file_size_bytes',
|
||||
|
||||
Reference in New Issue
Block a user