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:
@@ -2,5 +2,5 @@ type: added
|
|||||||
area: stats
|
area: stats
|
||||||
|
|
||||||
- Library: duplicate cards for the same show can now be combined. Press "Select" above the library grid, tick the cards, and use "Merge Selected"; the dialog picks which entry to keep and moves every episode onto it. Sessions, mined cards, and watch time are preserved, the emptied entries disappear, and remembered title aliases keep future episodes on the merged card.
|
- Library: duplicate cards for the same show can now be combined. Press "Select" above the library grid, tick the cards, and use "Merge Selected"; the dialog picks which entry to keep and moves every episode onto it. Sessions, mined cards, and watch time are preserved, the emptied entries disappear, and remembered title aliases keep future episodes on the merged card.
|
||||||
- Library: episodes can be reassigned to another library entry from the "→" button on an episode row, which is the fix when one file lands under a stray title (e.g. an episode name parsed as the series). Emptying an entry this way removes it and returns to the grid.
|
- Library: episodes can be reassigned to another library entry from the "→" button on an episode row, which is the fix when one file lands under a stray title (e.g. an episode name parsed as the series). Manual assignments now survive later filename parsing, Jellyfin refreshes, and season repair. Compatible local episodes in the same directory reuse a uniquely corrected destination, while conflicting seasons or manual destinations are not forced together. Emptying an entry this way removes it and returns to the grid.
|
||||||
- Library: exact AniList title matches with compatible seasons fold duplicate cards automatically. Fuzzy same-AniList matches appear as dismissible "Possible duplicate" reviews instead of changing the library without confirmation; conflicting explicit seasons are left alone.
|
- Library: exact AniList title matches with compatible seasons fold duplicate cards automatically. Fuzzy same-AniList matches appear as dismissible "Possible duplicate" reviews instead of changing the library without confirmation; conflicting explicit seasons are left alone.
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ When YouTube channel metadata is available, the Library tab groups videos by cre
|
|||||||
A library entry is identified by its parsed title plus any detected season, so the same show can end up on several cards when releases disagree about the title or omit the season tag. Two fixes are available:
|
A library entry is identified by its parsed title plus any detected season, so the same show can end up on several cards when releases disagree about the title or omit the season tag. Two fixes are available:
|
||||||
|
|
||||||
- **Merge duplicates.** Hit **Select** above the grid, tick the cards that are the same show, and choose **Merge Selected**. Pick which entry to keep in the dialog; every episode moves onto it and the other cards are removed. Nothing is deleted, so sessions, mined cards and watch time all carry over. SubMiner remembers the merged title variants, so future episodes parsed with one of those names join the kept entry instead of recreating a duplicate card.
|
- **Merge duplicates.** Hit **Select** above the grid, tick the cards that are the same show, and choose **Merge Selected**. Pick which entry to keep in the dialog; every episode moves onto it and the other cards are removed. Nothing is deleted, so sessions, mined cards and watch time all carry over. SubMiner remembers the merged title variants, so future episodes parsed with one of those names join the kept entry instead of recreating a duplicate card.
|
||||||
- **Move a single episode.** Hover an episode row in a title's episode list and use the **→** button to reassign it to another library entry. If that was the entry's last episode, the now-empty card is removed and you are returned to the grid.
|
- **Move a single episode.** Hover an episode row in a title's episode list and use the **→** button to reassign it to another library entry. The correction is remembered, so later filename parsing or Jellyfin metadata cannot move that episode back. For local files, later episodes in the same directory inherit the correction when their detected seasons are compatible and every manual correction there points to the same entry. Conflicting seasons or manual destinations are left for review. If the move empties the old entry, that card is removed and you are returned to the grid.
|
||||||
|
|
||||||
Once cover art resolves a series to an AniList entry, cards with compatible seasons are folded together automatically only when the searched title exactly matches an AniList title or synonym. A fuzzy result that points at an AniList entry already used by another card appears as a **Possible duplicate** review above the Library grid instead. Choose **Review merge** to compare the cards and pick which one to keep, or **Not duplicates** to dismiss that suggestion permanently. Entries with conflicting explicit season numbers are left alone rather than merged or suggested.
|
Once cover art resolves a series to an AniList entry, cards with compatible seasons are folded together automatically only when the searched title exactly matches an AniList title or synonym. A fuzzy result that points at an AniList entry already used by another card appears as a **Possible duplicate** review above the Library grid instead. Choose **Review merge** to compare the cards and pick which one to keep, or **Not duplicates** to dismiss that suggestion permanently. Entries with conflicting explicit season numbers are left alone rather than merged or suggested.
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,11 @@ test('merges remote-only sessions with catalog, lifetime, and rollups', () => {
|
|||||||
{ headword: '食べる', word: '食べた', reading: 'たべた', count: 1 },
|
{ headword: '食べる', word: '食べた', reading: 'たべた', count: 1 },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
withWritableDb(remotePath, (db) => {
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE imm_videos SET anime_assignment_locked = 1 WHERE video_key = 'showb-e1'`,
|
||||||
|
).run();
|
||||||
|
});
|
||||||
|
|
||||||
const summary = mergeSnapshotIntoDb(localPath, remotePath);
|
const summary = mergeSnapshotIntoDb(localPath, remotePath);
|
||||||
assert.equal(summary.sessionsMerged, 1);
|
assert.equal(summary.sessionsMerged, 1);
|
||||||
@@ -126,6 +131,14 @@ test('merges remote-only sessions with catalog, lifetime, and rollups', () => {
|
|||||||
`SELECT video_id FROM imm_videos WHERE video_key = 'showb-e1'`,
|
`SELECT video_id FROM imm_videos WHERE video_key = 'showb-e1'`,
|
||||||
)?.video_id,
|
)?.video_id,
|
||||||
);
|
);
|
||||||
|
assert.equal(
|
||||||
|
queryOne<{ locked: number }>(
|
||||||
|
localPath,
|
||||||
|
'SELECT anime_assignment_locked AS locked FROM imm_videos WHERE video_id = ?',
|
||||||
|
[mergedVideoId],
|
||||||
|
)?.locked,
|
||||||
|
1,
|
||||||
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
count(localPath, 'SELECT COUNT(*) AS n FROM imm_daily_rollups WHERE video_id = ?', [
|
count(localPath, 'SELECT COUNT(*) AS n FROM imm_daily_rollups WHERE video_id = ?', [
|
||||||
mergedVideoId,
|
mergedVideoId,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Schema-version-18 shape of the tables the sync merge touches (plus the
|
// Current schema shape of the tables the sync merge touches (plus the
|
||||||
// app's indexes), mirroring ensureSchema / ensureLifetimeSummaryTables /
|
// app's indexes), mirroring ensureSchema / ensureLifetimeSummaryTables /
|
||||||
// ensureStatsExcludedWordsTable in src/core/services/immersion-tracker/storage.ts.
|
// ensureStatsExcludedWordsTable in src/core/services/immersion-tracker/storage.ts.
|
||||||
export const IMMERSION_DB_FIXTURE_DDL = `
|
export const IMMERSION_DB_FIXTURE_DDL = `
|
||||||
@@ -39,6 +39,7 @@ export const IMMERSION_DB_FIXTURE_DDL = `
|
|||||||
parser_source TEXT,
|
parser_source TEXT,
|
||||||
parser_confidence REAL,
|
parser_confidence REAL,
|
||||||
parse_metadata_json TEXT,
|
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,
|
watched INTEGER NOT NULL DEFAULT 0,
|
||||||
duration_ms INTEGER NOT NULL CHECK(duration_ms>=0),
|
duration_ms INTEGER NOT NULL CHECK(duration_ms>=0),
|
||||||
file_size_bytes INTEGER CHECK(file_size_bytes>=0),
|
file_size_bytes INTEGER CHECK(file_size_bytes>=0),
|
||||||
|
|||||||
@@ -397,7 +397,14 @@ export async function resolveAnilistSeasonMedia(
|
|||||||
deps.logInfo?.(
|
deps.logInfo?.(
|
||||||
`[anilist] season ${season} of "${searchTitle}" resolved via sequel chain: ${displayTitle(viaChain, searchTitle)} (${viaChain.id})`,
|
`[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);
|
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 () => {
|
test('handleMediaChange splits matching parsed titles across distinct seasons', async () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
let tracker: ImmersionTrackerService | null = null;
|
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 () => {
|
test('startup repairs existing Jellyfin stream video links to metadata rows', async () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
let tracker: ImmersionTrackerService | null = null;
|
let tracker: ImmersionTrackerService | null = null;
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import {
|
|||||||
applyPragmas,
|
applyPragmas,
|
||||||
createTrackerPreparedStatements,
|
createTrackerPreparedStatements,
|
||||||
ensureSchema,
|
ensureSchema,
|
||||||
|
findManualDirectoryAnimeAssignment,
|
||||||
|
getManualAnimeAssignment,
|
||||||
executeQueuedWrite,
|
executeQueuedWrite,
|
||||||
getOrCreateAnimeRecord,
|
getOrCreateAnimeRecord,
|
||||||
getOrCreateVideoRecord,
|
getOrCreateVideoRecord,
|
||||||
@@ -1421,16 +1423,18 @@ export class ImmersionTrackerService {
|
|||||||
seasonNumber,
|
seasonNumber,
|
||||||
episodeNumber,
|
episodeNumber,
|
||||||
});
|
});
|
||||||
const animeId = getOrCreateAnimeRecord(this.db, {
|
const animeId =
|
||||||
parsedTitle: libraryTitle,
|
getManualAnimeAssignment(this.db, videoId) ??
|
||||||
canonicalTitle: libraryTitle,
|
getOrCreateAnimeRecord(this.db, {
|
||||||
seasonScope: seasonNumber,
|
parsedTitle: libraryTitle,
|
||||||
anilistId: null,
|
canonicalTitle: libraryTitle,
|
||||||
titleRomaji: null,
|
seasonScope: seasonNumber,
|
||||||
titleEnglish: null,
|
anilistId: null,
|
||||||
titleNative: null,
|
titleRomaji: null,
|
||||||
metadataJson,
|
titleEnglish: null,
|
||||||
});
|
titleNative: null,
|
||||||
|
metadataJson,
|
||||||
|
});
|
||||||
linkVideoToAnimeRecord(this.db, videoId, {
|
linkVideoToAnimeRecord(this.db, videoId, {
|
||||||
animeId,
|
animeId,
|
||||||
parsedBasename: null,
|
parsedBasename: null,
|
||||||
@@ -2067,16 +2071,21 @@ export class ImmersionTrackerService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const animeId = getOrCreateAnimeRecord(this.db, {
|
const animeId =
|
||||||
parsedTitle: parsed.parsedTitle,
|
getManualAnimeAssignment(this.db, videoId) ??
|
||||||
canonicalTitle: parsed.parsedTitle,
|
(mediaPath && !isRemoteSource(mediaPath)
|
||||||
seasonScope: parsed.parsedSeason,
|
? findManualDirectoryAnimeAssignment(this.db, videoId, mediaPath, parsed.parsedSeason)
|
||||||
anilistId: null,
|
: null) ??
|
||||||
titleRomaji: null,
|
getOrCreateAnimeRecord(this.db, {
|
||||||
titleEnglish: null,
|
parsedTitle: parsed.parsedTitle,
|
||||||
titleNative: null,
|
canonicalTitle: parsed.parsedTitle,
|
||||||
metadataJson: parsed.parseMetadataJson,
|
seasonScope: parsed.parsedSeason,
|
||||||
});
|
anilistId: null,
|
||||||
|
titleRomaji: null,
|
||||||
|
titleEnglish: null,
|
||||||
|
titleNative: null,
|
||||||
|
metadataJson: parsed.parseMetadataJson,
|
||||||
|
});
|
||||||
linkVideoToAnimeRecord(this.db, videoId, {
|
linkVideoToAnimeRecord(this.db, videoId, {
|
||||||
animeId,
|
animeId,
|
||||||
parsedBasename: parsed.parsedBasename,
|
parsedBasename: parsed.parsedBasename,
|
||||||
|
|||||||
@@ -5,7 +5,14 @@ import path from 'node:path';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { Database } from '../sqlite.js';
|
import { Database } from '../sqlite.js';
|
||||||
import type { DatabaseSync } 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 { mergeAnimeRecords, moveVideoToAnime } from '../anime-merge.js';
|
||||||
import {
|
import {
|
||||||
dismissAnimeMergeRecommendation,
|
dismissAnimeMergeRecommendation,
|
||||||
@@ -140,6 +147,14 @@ function videoAnimeId(db: DatabaseSync, videoId: number): number | null {
|
|||||||
).id;
|
).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 {
|
function lineAnimeIds(db: DatabaseSync, animeId: number): number {
|
||||||
return 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.activeMs, 7000);
|
||||||
assert.equal(survivor.cards, 9);
|
assert.equal(survivor.cards, 9);
|
||||||
assert.equal(survivor.episodes, 3);
|
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.equal(summary.removedPreviousAnime, true);
|
||||||
assert.deepEqual(animeIds(db), [1]);
|
assert.deepEqual(animeIds(db), [1]);
|
||||||
assert.equal(videoAnimeId(db, 2), 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);
|
assert.equal(lineAnimeIds(db, 1), 2);
|
||||||
const lifetime = db
|
const lifetime = db
|
||||||
.prepare('SELECT total_active_ms AS activeMs FROM imm_lifetime_anime WHERE anime_id = 1')
|
.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.equal(summary.removedPreviousAnime, false);
|
||||||
assert.deepEqual(animeIds(db), [1]);
|
assert.deepEqual(animeIds(db), [1]);
|
||||||
assert.equal(videoAnimeId(db, 1), 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', () => {
|
test('resolveAnimeAnilistConflict leaves explicit incompatible seasons and assignments unchanged', () => {
|
||||||
withDb((db) => {
|
withDb((db) => {
|
||||||
insertAnime(db, {
|
insertAnime(db, {
|
||||||
|
|||||||
@@ -263,15 +263,18 @@ export function moveVideoToAnime(
|
|||||||
|
|
||||||
const previousAnimeId = videoRow.animeId;
|
const previousAnimeId = videoRow.animeId;
|
||||||
if (previousAnimeId === targetAnimeId) {
|
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 };
|
return { targetAnimeId, previousAnimeId, removedPreviousAnime: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedAt = toDbTimestamp(nowMs());
|
const updatedAt = toDbTimestamp(nowMs());
|
||||||
db.prepare('UPDATE imm_videos SET anime_id = ?, LAST_UPDATE_DATE = ? WHERE video_id = ?').run(
|
db.prepare(
|
||||||
targetAnimeId,
|
`UPDATE imm_videos
|
||||||
updatedAt,
|
SET anime_id = ?, anime_assignment_locked = 1, LAST_UPDATE_DATE = ?
|
||||||
videoId,
|
WHERE video_id = ?`,
|
||||||
);
|
).run(targetAnimeId, updatedAt, videoId);
|
||||||
db.prepare(
|
db.prepare(
|
||||||
'UPDATE imm_subtitle_lines SET anime_id = ?, LAST_UPDATE_DATE = ? WHERE video_id = ?',
|
'UPDATE imm_subtitle_lines SET anime_id = ?, LAST_UPDATE_DATE = ? WHERE video_id = ?',
|
||||||
).run(targetAnimeId, updatedAt, videoId);
|
).run(targetAnimeId, updatedAt, videoId);
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ interface ParsedVideoRow {
|
|||||||
video_id: number;
|
video_id: number;
|
||||||
parsed_title: string | null;
|
parsed_title: string | null;
|
||||||
parsed_season: number | null;
|
parsed_season: number | null;
|
||||||
|
anime_assignment_locked: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RedistributeOptions {
|
interface RedistributeOptions {
|
||||||
@@ -137,7 +138,7 @@ function getParsedVideos(db: DatabaseSync, animeId: number): ParsedVideoRow[] {
|
|||||||
return db
|
return db
|
||||||
.prepare(
|
.prepare(
|
||||||
`
|
`
|
||||||
SELECT video_id, parsed_title, parsed_season
|
SELECT video_id, parsed_title, parsed_season, anime_assignment_locked
|
||||||
FROM imm_videos
|
FROM imm_videos
|
||||||
WHERE anime_id = ?
|
WHERE anime_id = ?
|
||||||
ORDER BY video_id ASC
|
ORDER BY video_id ASC
|
||||||
@@ -231,6 +232,9 @@ function redistributeAnimeRowByParsedSeasonsInTransaction(
|
|||||||
const targetBySeason = new Map<number, number>();
|
const targetBySeason = new Map<number, number>();
|
||||||
|
|
||||||
for (const video of videos) {
|
for (const video of videos) {
|
||||||
|
if (video.anime_assignment_locked === 1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const parsedTitle = video.parsed_title?.trim();
|
const parsedTitle = video.parsed_title?.trim();
|
||||||
const season = normalizeSeason(video.parsed_season);
|
const season = normalizeSeason(video.parsed_season);
|
||||||
if (!parsedTitle || season === null) {
|
if (!parsedTitle || season === null) {
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import type { JellyfinLinkRepairSummary } from './types';
|
|||||||
type LegacyJellyfinVideoRow = {
|
type LegacyJellyfinVideoRow = {
|
||||||
video_id: number;
|
video_id: number;
|
||||||
video_key: string;
|
video_key: string;
|
||||||
|
anime_id: number | null;
|
||||||
|
anime_assignment_locked: number;
|
||||||
source_url: string | null;
|
source_url: string | null;
|
||||||
canonical_title: string;
|
canonical_title: string;
|
||||||
};
|
};
|
||||||
@@ -15,6 +17,7 @@ type LegacyJellyfinVideoRow = {
|
|||||||
type JellyfinTargetVideoRow = {
|
type JellyfinTargetVideoRow = {
|
||||||
video_id: number;
|
video_id: number;
|
||||||
anime_id: number | null;
|
anime_id: number | null;
|
||||||
|
anime_assignment_locked: number;
|
||||||
canonical_title: string;
|
canonical_title: string;
|
||||||
parsed_basename: string | null;
|
parsed_basename: string | null;
|
||||||
parsed_title: string | null;
|
parsed_title: string | null;
|
||||||
@@ -258,7 +261,13 @@ export function repairJellyfinStreamVideoLinks(db: DatabaseSync): JellyfinLinkRe
|
|||||||
const candidates = db
|
const candidates = db
|
||||||
.prepare(
|
.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
|
FROM imm_videos
|
||||||
WHERE source_type = 2
|
WHERE source_type = 2
|
||||||
AND (
|
AND (
|
||||||
@@ -310,6 +319,7 @@ export function repairJellyfinStreamVideoLinks(db: DatabaseSync): JellyfinLinkRe
|
|||||||
SELECT
|
SELECT
|
||||||
video_id,
|
video_id,
|
||||||
anime_id,
|
anime_id,
|
||||||
|
anime_assignment_locked,
|
||||||
canonical_title,
|
canonical_title,
|
||||||
parsed_basename,
|
parsed_basename,
|
||||||
parsed_title,
|
parsed_title,
|
||||||
@@ -357,12 +367,17 @@ export function repairJellyfinStreamVideoLinks(db: DatabaseSync): JellyfinLinkRe
|
|||||||
continue;
|
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(
|
db.prepare(
|
||||||
`
|
`
|
||||||
UPDATE imm_videos
|
UPDATE imm_videos
|
||||||
SET
|
SET
|
||||||
video_key = ?,
|
video_key = ?,
|
||||||
anime_id = ?,
|
anime_id = ?,
|
||||||
|
anime_assignment_locked = ?,
|
||||||
canonical_title = ?,
|
canonical_title = ?,
|
||||||
source_url = ?,
|
source_url = ?,
|
||||||
parsed_basename = ?,
|
parsed_basename = ?,
|
||||||
@@ -377,7 +392,8 @@ export function repairJellyfinStreamVideoLinks(db: DatabaseSync): JellyfinLinkRe
|
|||||||
`,
|
`,
|
||||||
).run(
|
).run(
|
||||||
sanitizedVideoKey,
|
sanitizedVideoKey,
|
||||||
target.anime_id,
|
assignmentAnimeId,
|
||||||
|
assignmentLocked,
|
||||||
target.canonical_title,
|
target.canonical_title,
|
||||||
statsUrl,
|
statsUrl,
|
||||||
target.parsed_basename,
|
target.parsed_basename,
|
||||||
@@ -390,14 +406,14 @@ export function repairJellyfinStreamVideoLinks(db: DatabaseSync): JellyfinLinkRe
|
|||||||
currentTimestamp,
|
currentTimestamp,
|
||||||
candidate.video_id,
|
candidate.video_id,
|
||||||
);
|
);
|
||||||
if (target.anime_id !== null) {
|
if (assignmentAnimeId !== null) {
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`
|
`
|
||||||
UPDATE imm_subtitle_lines
|
UPDATE imm_subtitle_lines
|
||||||
SET anime_id = ?, LAST_UPDATE_DATE = ?
|
SET anime_id = ?, LAST_UPDATE_DATE = ?
|
||||||
WHERE video_id = ?
|
WHERE video_id = ?
|
||||||
`,
|
`,
|
||||||
).run(target.anime_id, currentTimestamp, candidate.video_id);
|
).run(assignmentAnimeId, currentTimestamp, candidate.video_id);
|
||||||
}
|
}
|
||||||
summary.repaired += 1;
|
summary.repaired += 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
} from './storage';
|
} from './storage';
|
||||||
import {
|
import {
|
||||||
EVENT_SUBTITLE_LINE,
|
EVENT_SUBTITLE_LINE,
|
||||||
|
SCHEMA_VERSION,
|
||||||
SESSION_STATUS_ENDED,
|
SESSION_STATUS_ENDED,
|
||||||
SOURCE_TYPE_LOCAL,
|
SOURCE_TYPE_LOCAL,
|
||||||
SOURCE_TYPE_REMOTE,
|
SOURCE_TYPE_REMOTE,
|
||||||
@@ -132,6 +133,7 @@ test('ensureSchema creates immersion core tables', () => {
|
|||||||
assert.ok(videoColumns.has('parser_source'));
|
assert.ok(videoColumns.has('parser_source'));
|
||||||
assert.ok(videoColumns.has('parser_confidence'));
|
assert.ok(videoColumns.has('parser_confidence'));
|
||||||
assert.ok(videoColumns.has('parse_metadata_json'));
|
assert.ok(videoColumns.has('parse_metadata_json'));
|
||||||
|
assert.ok(videoColumns.has('anime_assignment_locked'));
|
||||||
|
|
||||||
const mediaArtColumns = new Set(
|
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', () => {
|
test('stats excluded words are replaced and read from sqlite storage', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
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_source'));
|
||||||
assert.ok(videoColumns.has('parser_confidence'));
|
assert.ok(videoColumns.has('parser_confidence'));
|
||||||
assert.ok(videoColumns.has('parse_metadata_json'));
|
assert.ok(videoColumns.has('parse_metadata_json'));
|
||||||
|
assert.ok(videoColumns.has('anime_assignment_locked'));
|
||||||
|
|
||||||
const animeRows = db
|
const animeRows = db
|
||||||
.prepare('SELECT canonical_title FROM imm_anime ORDER BY canonical_title')
|
.prepare('SELECT canonical_title FROM imm_anime ORDER BY canonical_title')
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
|
import path from 'node:path';
|
||||||
import { parseMediaInfo } from '../../../jimaku/utils';
|
import { parseMediaInfo } from '../../../jimaku/utils';
|
||||||
import { normalizeTitleIdentity } from '../../utils/title-normalization';
|
import { normalizeTitleIdentity } from '../../utils/title-normalization';
|
||||||
import type { DatabaseSync } from './sqlite';
|
import type { DatabaseSync } from './sqlite';
|
||||||
@@ -648,7 +649,10 @@ export function linkVideoToAnimeRecord(
|
|||||||
`
|
`
|
||||||
UPDATE imm_videos
|
UPDATE imm_videos
|
||||||
SET
|
SET
|
||||||
anime_id = ?,
|
anime_id = CASE
|
||||||
|
WHEN anime_assignment_locked = 1 THEN anime_id
|
||||||
|
ELSE ?
|
||||||
|
END,
|
||||||
parsed_basename = ?,
|
parsed_basename = ?,
|
||||||
parsed_title = ?,
|
parsed_title = ?,
|
||||||
parsed_season = ?,
|
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(
|
export function linkYoutubeVideoToAnimeRecord(
|
||||||
db: DatabaseSync,
|
db: DatabaseSync,
|
||||||
videoId: number,
|
videoId: number,
|
||||||
@@ -817,6 +882,7 @@ export function ensureSchema(db: DatabaseSync): void {
|
|||||||
parser_source TEXT,
|
parser_source TEXT,
|
||||||
parser_confidence REAL,
|
parser_confidence REAL,
|
||||||
parse_metadata_json TEXT,
|
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,
|
watched INTEGER NOT NULL DEFAULT 0,
|
||||||
duration_ms INTEGER NOT NULL CHECK(duration_ms>=0),
|
duration_ms INTEGER NOT NULL CHECK(duration_ms>=0),
|
||||||
file_size_bytes INTEGER CHECK(file_size_bytes>=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
|
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);
|
ensureAnimeMergeTables(db);
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS imm_sessions(
|
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_QUEUE_CAP = 1_000;
|
||||||
export const DEFAULT_BATCH_SIZE = 25;
|
export const DEFAULT_BATCH_SIZE = 25;
|
||||||
export const DEFAULT_FLUSH_INTERVAL_MS = 500;
|
export const DEFAULT_FLUSH_INTERVAL_MS = 500;
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const VIDEO_COPY_COLUMNS = [
|
|||||||
'parser_source',
|
'parser_source',
|
||||||
'parser_confidence',
|
'parser_confidence',
|
||||||
'parse_metadata_json',
|
'parse_metadata_json',
|
||||||
|
'anime_assignment_locked',
|
||||||
'watched',
|
'watched',
|
||||||
'duration_ms',
|
'duration_ms',
|
||||||
'file_size_bytes',
|
'file_size_bytes',
|
||||||
|
|||||||
Reference in New Issue
Block a user