mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-21 17:16:20 -07:00
feat(stats): add TMDB metadata for live-action dramas in the Library (#252)
This commit is contained in:
@@ -0,0 +1,309 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
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 { repairLegacySeasonlessAnimeRows } from '../anime-season-repair.js';
|
||||
import { mergeAnimeRecords, mergeAnimeRecordsInTransaction } from '../anime-merge.js';
|
||||
import { getVideoTmdbLink, linkAnimeToTmdbTitle } from '../live-action-link.js';
|
||||
import { getAnimeCoverArt, getCoverArt } from '../query-library.js';
|
||||
import { clearAnimeCoverArt, upsertCoverArt } from '../query-maintenance.js';
|
||||
|
||||
const BASE_MS = 1_700_000_000_000;
|
||||
|
||||
function withDb(work: (db: DatabaseSync) => void): void {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-live-action-link-'));
|
||||
const db = new Database(path.join(dir, 'immersion.sqlite'));
|
||||
try {
|
||||
applyPragmas(db);
|
||||
ensureSchema(db);
|
||||
work(db);
|
||||
} finally {
|
||||
db.close();
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function insertAnime(
|
||||
db: DatabaseSync,
|
||||
animeId: number,
|
||||
title: string,
|
||||
anilistId: number | null = null,
|
||||
) {
|
||||
db.prepare(
|
||||
`INSERT INTO imm_anime(anime_id, normalized_title_key, canonical_title, anilist_id, CREATED_DATE, LAST_UPDATE_DATE)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
).run(animeId, title.toLowerCase(), title, anilistId, BASE_MS, BASE_MS);
|
||||
}
|
||||
|
||||
function insertEpisode(db: DatabaseSync, videoId: number, animeId: number, season: number | null) {
|
||||
db.prepare(
|
||||
`INSERT INTO imm_videos(video_id, video_key, anime_id, canonical_title, source_type, parsed_title, parsed_season, parsed_episode, watched, duration_ms, CREATED_DATE, LAST_UPDATE_DATE)
|
||||
VALUES (?, ?, ?, ?, 1, 'Hanzawa Naoki', ?, ?, 1, 1440000, ?, ?)`,
|
||||
).run(
|
||||
videoId,
|
||||
`local:/tmp/${videoId}.mkv`,
|
||||
animeId,
|
||||
`Ep ${videoId}`,
|
||||
season,
|
||||
videoId,
|
||||
BASE_MS,
|
||||
BASE_MS,
|
||||
);
|
||||
db.prepare(
|
||||
`INSERT INTO imm_lifetime_media(video_id, total_sessions, total_active_ms, total_cards, completed, first_watched_ms, last_watched_ms, CREATED_DATE, LAST_UPDATE_DATE)
|
||||
VALUES (?, 1, 1000, 0, 1, ?, ?, ?, ?)`,
|
||||
).run(videoId, String(BASE_MS), String(BASE_MS + 1000), BASE_MS, BASE_MS);
|
||||
}
|
||||
|
||||
interface AnimeRowView {
|
||||
mediaKind: string;
|
||||
tmdbId: number | null;
|
||||
tmdbType: string | null;
|
||||
anilistId: number | null;
|
||||
titleEnglish: string | null;
|
||||
titleNative: string | null;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
// Copies the selected columns so the driver's row metadata does not leak into
|
||||
// deep-equality assertions.
|
||||
function animeRow(db: DatabaseSync, animeId: number): AnimeRowView | undefined {
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT media_kind AS mediaKind, tmdb_id AS tmdbId, tmdb_type AS tmdbType, anilist_id AS anilistId,
|
||||
title_english AS titleEnglish, title_native AS titleNative, description
|
||||
FROM imm_anime WHERE anime_id = ?`,
|
||||
)
|
||||
.get(animeId) as AnimeRowView | undefined;
|
||||
if (!row) return undefined;
|
||||
const { mediaKind, tmdbId, tmdbType, anilistId, titleEnglish, titleNative, description } = row;
|
||||
return { mediaKind, tmdbId, tmdbType, anilistId, titleEnglish, titleNative, description };
|
||||
}
|
||||
|
||||
function animeCount(db: DatabaseSync): number {
|
||||
return (db.prepare('SELECT COUNT(*) AS n FROM imm_anime').get() as { n: number }).n;
|
||||
}
|
||||
|
||||
function videoOwner(db: DatabaseSync, videoId: number): number | null {
|
||||
return (
|
||||
db.prepare('SELECT anime_id AS animeId FROM imm_videos WHERE video_id = ?').get(videoId) as {
|
||||
animeId: number | null;
|
||||
}
|
||||
).animeId;
|
||||
}
|
||||
|
||||
const HANZAWA = {
|
||||
tmdbId: 61222,
|
||||
tmdbType: 'tv' as const,
|
||||
titleEnglish: 'Hanzawa Naoki',
|
||||
titleNative: '半沢直樹',
|
||||
description: 'A banker fights back.',
|
||||
episodesTotal: 10,
|
||||
};
|
||||
|
||||
test('a manual link overwrites metadata, drops the AniList link, and folds other holders in', () => {
|
||||
withDb((db) => {
|
||||
insertAnime(db, 1, 'Hanzawa Naoki', 4242);
|
||||
insertAnime(db, 2, 'Hanzawa Naoki Season 2');
|
||||
insertEpisode(db, 1, 1, 1);
|
||||
insertEpisode(db, 2, 2, 2);
|
||||
db.prepare(
|
||||
`UPDATE imm_anime SET media_kind = 'live_action', tmdb_id = ?, tmdb_type = 'tv', description = 'old' WHERE anime_id = 2`,
|
||||
).run(HANZAWA.tmdbId);
|
||||
|
||||
const result = linkAnimeToTmdbTitle(db, 1, HANZAWA, { mode: 'manual' });
|
||||
|
||||
assert.deepEqual(result, { animeId: 1, mergedAnimeIds: [2] });
|
||||
assert.deepEqual(animeRow(db, 1), {
|
||||
mediaKind: 'live_action',
|
||||
tmdbId: 61222,
|
||||
tmdbType: 'tv',
|
||||
anilistId: null,
|
||||
titleEnglish: 'Hanzawa Naoki',
|
||||
titleNative: '半沢直樹',
|
||||
description: 'A banker fights back.',
|
||||
});
|
||||
assert.equal(animeRow(db, 2), undefined);
|
||||
assert.equal(animeCount(db), 1);
|
||||
assert.equal(videoOwner(db, 2), 1);
|
||||
assert.deepEqual(getVideoTmdbLink(db, 2), { animeId: 1, tmdbId: 61222, tmdbType: 'tv' });
|
||||
});
|
||||
});
|
||||
|
||||
test('an automatic link joins the entry that already owns the title and only fills gaps', () => {
|
||||
withDb((db) => {
|
||||
insertAnime(db, 1, 'Hanzawa Naoki');
|
||||
insertEpisode(db, 1, 1, 1);
|
||||
linkAnimeToTmdbTitle(db, 1, { ...HANZAWA, description: 'kept' }, { mode: 'manual' });
|
||||
const newcomer = getOrCreateAnimeRecord(db, {
|
||||
parsedTitle: 'Hanzawa Naoki',
|
||||
canonicalTitle: 'Hanzawa Naoki',
|
||||
seasonScope: 2,
|
||||
anilistId: null,
|
||||
titleRomaji: null,
|
||||
titleEnglish: null,
|
||||
titleNative: null,
|
||||
metadataJson: null,
|
||||
});
|
||||
insertEpisode(db, 2, newcomer, 2);
|
||||
|
||||
const result = linkAnimeToTmdbTitle(db, newcomer, HANZAWA, { mode: 'auto' });
|
||||
|
||||
assert.deepEqual(result, { animeId: 1, mergedAnimeIds: [newcomer] });
|
||||
assert.equal(animeRow(db, 1)?.description, 'kept');
|
||||
assert.equal(animeCount(db), 1);
|
||||
assert.equal(videoOwner(db, 2), 1);
|
||||
// The merged-away season title is remembered, so the next episode of that
|
||||
// season lands on the survivor without a detour through a new row.
|
||||
const again = getOrCreateAnimeRecord(db, {
|
||||
parsedTitle: 'Hanzawa Naoki',
|
||||
canonicalTitle: 'Hanzawa Naoki',
|
||||
seasonScope: 2,
|
||||
anilistId: null,
|
||||
titleRomaji: null,
|
||||
titleEnglish: null,
|
||||
titleNative: null,
|
||||
metadataJson: null,
|
||||
});
|
||||
assert.equal(again, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test('startup season repair leaves multi-season live-action entries alone', () => {
|
||||
withDb((db) => {
|
||||
insertAnime(db, 1, 'Hanzawa Naoki');
|
||||
insertEpisode(db, 1, 1, 1);
|
||||
insertEpisode(db, 2, 1, 2);
|
||||
linkAnimeToTmdbTitle(db, 1, HANZAWA, { mode: 'manual' });
|
||||
|
||||
repairLegacySeasonlessAnimeRows(db);
|
||||
|
||||
assert.equal(animeCount(db), 1);
|
||||
assert.equal(videoOwner(db, 1), 1);
|
||||
assert.equal(videoOwner(db, 2), 1);
|
||||
assert.equal(getVideoTmdbLink(db, 2)?.animeId, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test('getVideoTmdbLink is null for anime entries and unlinked videos', () => {
|
||||
withDb((db) => {
|
||||
insertAnime(db, 1, 'Some Anime', 77);
|
||||
insertEpisode(db, 1, 1, 1);
|
||||
assert.equal(getVideoTmdbLink(db, 1), null);
|
||||
assert.equal(getVideoTmdbLink(db, 99), null);
|
||||
});
|
||||
});
|
||||
|
||||
test('clearAnimeCoverArt drops every episode cover of the entry and its orphaned blob', () => {
|
||||
withDb((db) => {
|
||||
insertAnime(db, 1, 'Hanzawa Naoki');
|
||||
insertAnime(db, 2, 'Other Show');
|
||||
insertEpisode(db, 1, 1, 1);
|
||||
insertEpisode(db, 2, 1, 1);
|
||||
insertEpisode(db, 3, 2, 1);
|
||||
const shared = Buffer.from([1, 2, 3]);
|
||||
for (const videoId of [1, 2]) {
|
||||
upsertCoverArt(db, videoId, {
|
||||
anilistId: 4242,
|
||||
coverUrl: 'https://images.test/a.jpg',
|
||||
coverBlob: shared,
|
||||
titleRomaji: null,
|
||||
titleEnglish: null,
|
||||
episodesTotal: null,
|
||||
});
|
||||
}
|
||||
upsertCoverArt(db, 3, {
|
||||
anilistId: 99,
|
||||
coverUrl: 'https://images.test/b.jpg',
|
||||
coverBlob: Buffer.from([9]),
|
||||
titleRomaji: null,
|
||||
titleEnglish: null,
|
||||
episodesTotal: null,
|
||||
});
|
||||
|
||||
clearAnimeCoverArt(db, 1);
|
||||
|
||||
assert.equal(getAnimeCoverArt(db, 1), null);
|
||||
assert.equal(getCoverArt(db, 3)?.coverBlob?.length, 1);
|
||||
const blobs = (
|
||||
db.prepare('SELECT COUNT(*) AS n FROM imm_cover_art_blobs').get() as { n: number }
|
||||
).n;
|
||||
assert.equal(blobs, 1);
|
||||
});
|
||||
});
|
||||
|
||||
for (const targetId of [1, 2, 3]) {
|
||||
test(`merge rejects mixed providers before moving any source into entry ${targetId}`, () => {
|
||||
withDb((db) => {
|
||||
insertAnime(db, 1, 'Anime', 77);
|
||||
insertAnime(db, 2, 'Drama');
|
||||
insertAnime(db, 3, 'Unlinked');
|
||||
insertEpisode(db, 1, 1, 1);
|
||||
insertEpisode(db, 2, 2, 1);
|
||||
db.exec(
|
||||
"UPDATE imm_anime SET media_kind = 'live_action', tmdb_id = 12, tmdb_type = 'tv' WHERE anime_id = 2",
|
||||
);
|
||||
for (const merge of [mergeAnimeRecords, mergeAnimeRecordsInTransaction]) {
|
||||
assert.throws(
|
||||
() => merge(db, targetId, [3, 1, 2]),
|
||||
/AniList-linked and TMDB-linked library entries cannot be merged/,
|
||||
);
|
||||
assert.equal(animeCount(db), 3);
|
||||
assert.equal(videoOwner(db, 1), 1);
|
||||
assert.equal(videoOwner(db, 2), 2);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
for (const mode of ['manual', 'auto'] as const) {
|
||||
test(`TMDB ${mode} linking rolls back the merge when the survivor update fails`, () => {
|
||||
withDb((db) => {
|
||||
insertAnime(db, 1, 'New entry');
|
||||
insertAnime(db, 2, 'Existing entry');
|
||||
insertEpisode(db, 1, 1, 1);
|
||||
insertEpisode(db, 2, 2, 2);
|
||||
db.prepare(
|
||||
"UPDATE imm_anime SET tmdb_id = ?, tmdb_type = 'tv', media_kind = 'live_action' WHERE anime_id = 2",
|
||||
).run(HANZAWA.tmdbId);
|
||||
db.exec(`CREATE TRIGGER reject_link BEFORE UPDATE ON imm_anime
|
||||
WHEN NEW.description = 'A banker fights back.'
|
||||
BEGIN SELECT RAISE(ABORT, 'rejected survivor update'); END`);
|
||||
assert.throws(
|
||||
() => linkAnimeToTmdbTitle(db, 1, HANZAWA, { mode }),
|
||||
/rejected survivor update/,
|
||||
);
|
||||
assert.equal(animeCount(db), 2);
|
||||
assert.equal(videoOwner(db, 1), 1);
|
||||
assert.equal(videoOwner(db, 2), 2);
|
||||
assert.equal(animeRow(db, 1)?.tmdbId, null);
|
||||
assert.equal(animeRow(db, 2)?.tmdbId, HANZAWA.tmdbId);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
for (const mode of ['manual', 'auto'] as const) {
|
||||
test(`${mode} TMDB linking refreshes completion totals without merging records`, () => {
|
||||
withDb((db) => {
|
||||
insertAnime(db, 1, 'Hanzawa Naoki');
|
||||
insertEpisode(db, 1, 1, 1);
|
||||
const completed = () =>
|
||||
(
|
||||
db
|
||||
.prepare('SELECT anime_completed AS count FROM imm_lifetime_global WHERE global_id = 1')
|
||||
.get() as { count: number }
|
||||
).count;
|
||||
assert.equal(completed(), 0);
|
||||
const result = linkAnimeToTmdbTitle(db, 1, { ...HANZAWA, episodesTotal: 1 }, { mode });
|
||||
assert.deepEqual(result.mergedAnimeIds, []);
|
||||
assert.equal(completed(), 1);
|
||||
linkAnimeToTmdbTitle(db, 1, { ...HANZAWA, episodesTotal: 2 }, { mode: 'manual' });
|
||||
assert.equal(completed(), 0);
|
||||
assert.equal(animeCount(db), 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { MediaKind } from '../../../shared/media-kind';
|
||||
import { shareTitleNamespace, type MediaKind } from '../../../shared/media-kind';
|
||||
import type { DatabaseSync } from './sqlite';
|
||||
import { recomputeLifetimeAnimeAggregatesInTransaction } from './lifetime';
|
||||
import { toDbTimestamp } from './query-shared';
|
||||
@@ -6,8 +6,12 @@ import { nowMs } from './time';
|
||||
|
||||
/** Thrown when a move names an episode or destination entry that is not there. */
|
||||
export const UNKNOWN_MOVE_TARGET_MESSAGE = 'Unknown episode or target library entry';
|
||||
/** Thrown when a merge or move would mix an anime entry with a YouTube channel. */
|
||||
export const MEDIA_KIND_MISMATCH_MESSAGE = 'Anime and YouTube channel entries cannot be combined';
|
||||
/** Thrown when a merge would combine an AniList-linked entry with a TMDB-linked one. */
|
||||
export const INCOMPATIBLE_PROVIDER_MERGE_MESSAGE =
|
||||
'AniList-linked and TMDB-linked library entries cannot be merged together';
|
||||
/** Thrown when a merge or move would mix a YouTube channel with an anime or live-action entry. */
|
||||
export const MEDIA_KIND_MISMATCH_MESSAGE =
|
||||
'YouTube channels cannot be combined with anime or live-action entries';
|
||||
|
||||
export interface AnimeMergeSummary {
|
||||
/** Library entry that owns every moved episode once the merge finishes. */
|
||||
@@ -33,6 +37,9 @@ interface AnimeMetadataRow {
|
||||
title_native: string | null;
|
||||
episodes_total: number | null;
|
||||
description: string | null;
|
||||
media_kind: string;
|
||||
tmdb_id: number | null;
|
||||
tmdb_type: string | null;
|
||||
}
|
||||
|
||||
function emptyMergeSummary(survivingAnimeId: number): AnimeMergeSummary {
|
||||
@@ -55,7 +62,8 @@ function readAnimeMetadata(db: DatabaseSync, animeId: number): AnimeMetadataRow
|
||||
return (db
|
||||
.prepare(
|
||||
`
|
||||
SELECT normalized_title_key, anilist_id, title_romaji, title_english, title_native, episodes_total, description
|
||||
SELECT normalized_title_key, anilist_id, title_romaji, title_english, title_native, episodes_total, description,
|
||||
media_kind, tmdb_id, tmdb_type
|
||||
FROM imm_anime
|
||||
WHERE anime_id = ?
|
||||
`,
|
||||
@@ -137,6 +145,12 @@ function absorbAnimeMetadata(
|
||||
title_native = COALESCE(title_native, ?),
|
||||
episodes_total = COALESCE(episodes_total, ?),
|
||||
description = COALESCE(description, ?),
|
||||
tmdb_id = COALESCE(tmdb_id, ?),
|
||||
tmdb_type = CASE WHEN tmdb_id IS NULL THEN ? ELSE tmdb_type END,
|
||||
media_kind = CASE
|
||||
WHEN anilist_id IS NULL AND tmdb_id IS NULL AND ? IS NOT NULL THEN ?
|
||||
ELSE media_kind
|
||||
END,
|
||||
LAST_UPDATE_DATE = ?
|
||||
WHERE anime_id = ?
|
||||
`,
|
||||
@@ -147,6 +161,10 @@ function absorbAnimeMetadata(
|
||||
source.title_native,
|
||||
source.episodes_total,
|
||||
source.description,
|
||||
source.tmdb_id,
|
||||
source.tmdb_type,
|
||||
source.tmdb_id,
|
||||
source.media_kind,
|
||||
updatedAt,
|
||||
targetAnimeId,
|
||||
);
|
||||
@@ -172,6 +190,18 @@ export function mergeAnimeRecordsInTransaction(
|
||||
return summary;
|
||||
}
|
||||
|
||||
// Validate the whole group before moving anything, including when the
|
||||
// unlinked target would inherit conflicting providers from two sources.
|
||||
const metadata = [targetAnimeId, ...new Set(sourceAnimeIds)].map((id) =>
|
||||
readAnimeMetadata(db, id),
|
||||
);
|
||||
if (
|
||||
metadata.some((row) => row?.anilist_id != null) &&
|
||||
metadata.some((row) => row?.tmdb_id != null)
|
||||
) {
|
||||
throw new Error(INCOMPATIBLE_PROVIDER_MERGE_MESSAGE);
|
||||
}
|
||||
|
||||
const updatedAt = toDbTimestamp(nowMs());
|
||||
const sourceVideosStmt = db.prepare(
|
||||
'SELECT video_id AS videoId FROM imm_videos WHERE anime_id = ?',
|
||||
@@ -206,8 +236,8 @@ export function mergeAnimeRecordsInTransaction(
|
||||
const sourceKind = readMediaKind(db, sourceAnimeId);
|
||||
if (sourceKind === null) continue;
|
||||
// A channel folded into an anime would only be recreated on the next
|
||||
// watch, because title lookups never cross kinds; refuse instead.
|
||||
if (sourceKind !== targetKind) {
|
||||
// watch, because title lookups never cross namespaces; refuse instead.
|
||||
if (!shareTitleNamespace(sourceKind, targetKind)) {
|
||||
throw new Error(MEDIA_KIND_MISMATCH_MESSAGE);
|
||||
}
|
||||
|
||||
@@ -275,7 +305,8 @@ export function moveVideoToAnime(
|
||||
}
|
||||
|
||||
const previousAnimeId = videoRow.animeId;
|
||||
if (previousAnimeId !== null && readMediaKind(db, previousAnimeId) !== targetKind) {
|
||||
const previousKind = previousAnimeId === null ? null : readMediaKind(db, previousAnimeId);
|
||||
if (previousKind !== null && !shareTitleNamespace(previousKind, targetKind)) {
|
||||
throw new Error(MEDIA_KIND_MISMATCH_MESSAGE);
|
||||
}
|
||||
if (previousAnimeId === targetAnimeId) {
|
||||
|
||||
@@ -134,7 +134,7 @@ function getAnimeRow(db: DatabaseSync, animeId: number): AnimeRow | null {
|
||||
episodes_total,
|
||||
description
|
||||
FROM imm_anime
|
||||
WHERE anime_id = ? AND media_kind = 'anime'
|
||||
WHERE anime_id = ? AND media_kind != 'youtube'
|
||||
`,
|
||||
)
|
||||
.get(animeId) as AnimeRow | null;
|
||||
@@ -372,6 +372,18 @@ export function resolveAnimeAnilistConflict(
|
||||
targetAnimeId: number,
|
||||
anilistId: number,
|
||||
options: AnimeAnilistConflictOptions = {},
|
||||
): AnimeSeasonRepairSummary {
|
||||
return runInTransaction(db, () =>
|
||||
resolveAnimeAnilistConflictInTransaction(db, targetAnimeId, anilistId, options),
|
||||
);
|
||||
}
|
||||
|
||||
/** Caller owns the write transaction. */
|
||||
export function resolveAnimeAnilistConflictInTransaction(
|
||||
db: DatabaseSync,
|
||||
targetAnimeId: number,
|
||||
anilistId: number,
|
||||
options: AnimeAnilistConflictOptions = {},
|
||||
): AnimeSeasonRepairSummary {
|
||||
if (!getAnimeRow(db, targetAnimeId)) {
|
||||
const summary = emptySummary();
|
||||
@@ -392,90 +404,88 @@ export function resolveAnimeAnilistConflict(
|
||||
if (!conflict) {
|
||||
return emptySummary();
|
||||
}
|
||||
|
||||
if (!getAnimeRow(db, conflict.animeId)) {
|
||||
const summary = emptySummary();
|
||||
summary.anilistAssignmentBlocked = true;
|
||||
return summary;
|
||||
}
|
||||
return runInTransaction(db, () => {
|
||||
const targetRow = getAnimeRow(db, targetAnimeId);
|
||||
if (
|
||||
options.survivor !== 'target' &&
|
||||
targetRow?.anilist_id != null &&
|
||||
targetRow.anilist_id !== anilistId
|
||||
) {
|
||||
// An automatic lookup disagreeing with an existing explicit link is a
|
||||
// mis-resolution, not evidence that either row should move or merge. The
|
||||
// colliding id must not be assigned either: another row owns it and
|
||||
// imm_anime.anilist_id is UNIQUE.
|
||||
const summary = emptySummary(1);
|
||||
summary.anilistAssignmentBlocked = true;
|
||||
return summary;
|
||||
}
|
||||
const isManual = options.survivor === 'target' || options.matchConfidence === 'manual';
|
||||
if (!isManual && hasDismissedAnimeMergeRecommendation(db, targetAnimeId, conflict.animeId)) {
|
||||
const summary = emptySummary(1);
|
||||
summary.anilistAssignmentBlocked = true;
|
||||
return summary;
|
||||
}
|
||||
const targetSeasons = getParsedSeasonsForAnime(db, targetAnimeId);
|
||||
const conflictSeasons = getParsedSeasonsForAnime(db, conflict.animeId);
|
||||
if (
|
||||
!isManual &&
|
||||
targetSeasons.size === 1 &&
|
||||
conflictSeasons.size === 1 &&
|
||||
[...targetSeasons][0] !== [...conflictSeasons][0]
|
||||
) {
|
||||
const summary = emptySummary(1);
|
||||
summary.anilistAssignmentBlocked = true;
|
||||
return summary;
|
||||
}
|
||||
if (canMergeAnilistConflict(db, targetAnimeId, conflict.animeId, anilistId, options)) {
|
||||
const survivingAnimeId = options.survivor === 'target' ? targetAnimeId : conflict.animeId;
|
||||
const absorbedAnimeId = survivingAnimeId === targetAnimeId ? conflict.animeId : targetAnimeId;
|
||||
const merge = mergeAnimeRecordsInTransaction(db, survivingAnimeId, [absorbedAnimeId]);
|
||||
const summary = emptySummary(1);
|
||||
summary.movedVideos = merge.movedVideos;
|
||||
summary.deletedAnimeRows = merge.mergedAnimeIds.length;
|
||||
if (merge.mergedAnimeIds.length > 0) {
|
||||
summary.repaired = 1;
|
||||
// Only reported once a row really absorbed the other, so callers never
|
||||
// follow this to an anime id that was never written.
|
||||
summary.survivingAnimeId = survivingAnimeId;
|
||||
summary.affectedAnimeIds.push(survivingAnimeId, absorbedAnimeId);
|
||||
}
|
||||
// Lifetime summaries are rebuilt by the caller off this summary, the same
|
||||
// as the redistribution path below.
|
||||
return summary;
|
||||
}
|
||||
|
||||
if (shouldRecommendAnilistConflict(db, targetAnimeId, conflict.animeId, options)) {
|
||||
recordAnimeMergeRecommendation(db, targetAnimeId, conflict.animeId, anilistId);
|
||||
const summary = emptySummary(1);
|
||||
summary.mergeRecommended = true;
|
||||
return summary;
|
||||
const targetRow = getAnimeRow(db, targetAnimeId);
|
||||
if (
|
||||
options.survivor !== 'target' &&
|
||||
targetRow?.anilist_id != null &&
|
||||
targetRow.anilist_id !== anilistId
|
||||
) {
|
||||
// An automatic lookup disagreeing with an existing explicit link is a
|
||||
// mis-resolution, not evidence that either row should move or merge. The
|
||||
// colliding id must not be assigned either: another row owns it and
|
||||
// imm_anime.anilist_id is UNIQUE.
|
||||
const summary = emptySummary(1);
|
||||
summary.anilistAssignmentBlocked = true;
|
||||
return summary;
|
||||
}
|
||||
const isManual = options.survivor === 'target' || options.matchConfidence === 'manual';
|
||||
if (!isManual && hasDismissedAnimeMergeRecommendation(db, targetAnimeId, conflict.animeId)) {
|
||||
const summary = emptySummary(1);
|
||||
summary.anilistAssignmentBlocked = true;
|
||||
return summary;
|
||||
}
|
||||
const targetSeasons = getParsedSeasonsForAnime(db, targetAnimeId);
|
||||
const conflictSeasons = getParsedSeasonsForAnime(db, conflict.animeId);
|
||||
if (
|
||||
!isManual &&
|
||||
targetSeasons.size === 1 &&
|
||||
conflictSeasons.size === 1 &&
|
||||
[...targetSeasons][0] !== [...conflictSeasons][0]
|
||||
) {
|
||||
const summary = emptySummary(1);
|
||||
summary.anilistAssignmentBlocked = true;
|
||||
return summary;
|
||||
}
|
||||
if (canMergeAnilistConflict(db, targetAnimeId, conflict.animeId, anilistId, options)) {
|
||||
const survivingAnimeId = options.survivor === 'target' ? targetAnimeId : conflict.animeId;
|
||||
const absorbedAnimeId = survivingAnimeId === targetAnimeId ? conflict.animeId : targetAnimeId;
|
||||
const merge = mergeAnimeRecordsInTransaction(db, survivingAnimeId, [absorbedAnimeId]);
|
||||
const summary = emptySummary(1);
|
||||
summary.movedVideos = merge.movedVideos;
|
||||
summary.deletedAnimeRows = merge.mergedAnimeIds.length;
|
||||
if (merge.mergedAnimeIds.length > 0) {
|
||||
summary.repaired = 1;
|
||||
// Only reported once a row really absorbed the other, so callers never
|
||||
// follow this to an anime id that was never written.
|
||||
summary.survivingAnimeId = survivingAnimeId;
|
||||
summary.affectedAnimeIds.push(survivingAnimeId, absorbedAnimeId);
|
||||
}
|
||||
// Lifetime summaries are rebuilt by the caller off this summary, the same
|
||||
// as the redistribution path below.
|
||||
return summary;
|
||||
}
|
||||
|
||||
const isExactAutomaticMatch =
|
||||
options.matchConfidence === 'exact' ||
|
||||
(options.matchConfidence === undefined &&
|
||||
hasExactStoredTitleMatch(db, targetAnimeId, conflict.animeId));
|
||||
if (!isManual && !isExactAutomaticMatch) {
|
||||
// Redistribution dismantles the id's current owner and hands the id to
|
||||
// the target. On a weak automatic match that owner is usually the
|
||||
// correctly linked card (e.g. a legitimate multi-season entry), so
|
||||
// splitting it here is exactly the fuzzy false merge this gate exists to
|
||||
// stop. Only exact or manual evidence may fall through.
|
||||
const summary = emptySummary(1);
|
||||
summary.anilistAssignmentBlocked = true;
|
||||
return summary;
|
||||
}
|
||||
if (shouldRecommendAnilistConflict(db, targetAnimeId, conflict.animeId, options)) {
|
||||
recordAnimeMergeRecommendation(db, targetAnimeId, conflict.animeId, anilistId);
|
||||
const summary = emptySummary(1);
|
||||
summary.mergeRecommended = true;
|
||||
return summary;
|
||||
}
|
||||
|
||||
return redistributeAnimeRowByParsedSeasonsInTransaction(db, conflict.animeId, {
|
||||
transferAnilistToAnimeId: targetAnimeId,
|
||||
overwriteTargetAnilist: true,
|
||||
});
|
||||
const isExactAutomaticMatch =
|
||||
options.matchConfidence === 'exact' ||
|
||||
(options.matchConfidence === undefined &&
|
||||
hasExactStoredTitleMatch(db, targetAnimeId, conflict.animeId));
|
||||
if (!isManual && !isExactAutomaticMatch) {
|
||||
// Redistribution dismantles the id's current owner and hands the id to
|
||||
// the target. On a weak automatic match that owner is usually the
|
||||
// correctly linked card (e.g. a legitimate multi-season entry), so
|
||||
// splitting it here is exactly the fuzzy false merge this gate exists to
|
||||
// stop. Only exact or manual evidence may fall through.
|
||||
const summary = emptySummary(1);
|
||||
summary.anilistAssignmentBlocked = true;
|
||||
return summary;
|
||||
}
|
||||
|
||||
return redistributeAnimeRowByParsedSeasonsInTransaction(db, conflict.animeId, {
|
||||
transferAnilistToAnimeId: targetAnimeId,
|
||||
overwriteTargetAnilist: true,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import type { DatabaseSync } from './sqlite';
|
||||
import type { TmdbMediaType } from '../../../shared/media-kind';
|
||||
import { mergeAnimeRecordsInTransaction } from './anime-merge';
|
||||
import { recomputeLifetimeAnimeAggregatesInTransaction } from './lifetime';
|
||||
import { toDbTimestamp } from './query-shared';
|
||||
import { nowMs } from './time';
|
||||
|
||||
export interface LiveActionTitleInput {
|
||||
tmdbId: number;
|
||||
tmdbType: TmdbMediaType;
|
||||
titleEnglish: string | null;
|
||||
titleNative: string | null;
|
||||
description: string | null;
|
||||
episodesTotal: number | null;
|
||||
}
|
||||
|
||||
export interface LiveActionLinkResult {
|
||||
/** Library entry that carries the TMDB link once the call finishes. */
|
||||
animeId: number;
|
||||
/** Entries folded into `animeId` because they pointed at the same TMDB title. */
|
||||
mergedAnimeIds: number[];
|
||||
}
|
||||
|
||||
export interface LiveActionLinkOptions {
|
||||
/**
|
||||
* `manual`: the user picked this title, so stored titles are overwritten and
|
||||
* every other holder of the TMDB id is folded into this entry.
|
||||
* `auto`: an exact filename match, so gaps are filled and the entry joins an
|
||||
* existing holder rather than displacing it.
|
||||
*/
|
||||
mode: 'manual' | 'auto';
|
||||
}
|
||||
|
||||
export interface VideoTmdbLink {
|
||||
animeId: number;
|
||||
tmdbId: number;
|
||||
tmdbType: TmdbMediaType;
|
||||
}
|
||||
|
||||
function findOtherTmdbHolders(
|
||||
db: DatabaseSync,
|
||||
animeId: number,
|
||||
input: Pick<LiveActionTitleInput, 'tmdbId' | 'tmdbType'>,
|
||||
): number[] {
|
||||
return (
|
||||
db
|
||||
.prepare(
|
||||
`SELECT anime_id AS animeId
|
||||
FROM imm_anime
|
||||
WHERE tmdb_id = ? AND tmdb_type = ? AND anime_id != ?
|
||||
ORDER BY anime_id ASC`,
|
||||
)
|
||||
.all(input.tmdbId, input.tmdbType, animeId) as Array<{ animeId: number }>
|
||||
).map((row) => row.animeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Link a library entry to a TMDB title. Unlike AniList, a TMDB show spans all
|
||||
* of its seasons, so entries that resolve to the same title are one show and
|
||||
* are merged regardless of the season each was parsed with.
|
||||
*/
|
||||
export function linkAnimeToTmdbTitle(
|
||||
db: DatabaseSync,
|
||||
animeId: number,
|
||||
input: LiveActionTitleInput,
|
||||
options: LiveActionLinkOptions,
|
||||
): LiveActionLinkResult {
|
||||
db.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
const result = linkAnimeToTmdbTitleInTransaction(db, animeId, input, options);
|
||||
db.exec('COMMIT');
|
||||
return result;
|
||||
} catch (error) {
|
||||
db.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Caller owns the write transaction, including any artwork replacement. */
|
||||
export function linkAnimeToTmdbTitleInTransaction(
|
||||
db: DatabaseSync,
|
||||
animeId: number,
|
||||
input: LiveActionTitleInput,
|
||||
options: LiveActionLinkOptions,
|
||||
): LiveActionLinkResult {
|
||||
const target = db.prepare('SELECT anilist_id FROM imm_anime WHERE anime_id = ?').get(animeId) as
|
||||
| { anilist_id: number | null }
|
||||
| undefined;
|
||||
if (!target) throw new Error('Unknown library entry');
|
||||
if (target.anilist_id !== null) {
|
||||
if (options.mode === 'auto')
|
||||
throw new Error('Cannot automatically replace an AniList identity');
|
||||
// An explicit reassignment changes providers before compatible rows merge.
|
||||
db.prepare('UPDATE imm_anime SET anilist_id = NULL WHERE anime_id = ?').run(animeId);
|
||||
}
|
||||
const others = findOtherTmdbHolders(db, animeId, input);
|
||||
let survivor = animeId;
|
||||
let mergedAnimeIds: number[] = [];
|
||||
if (others.length > 0) {
|
||||
if (options.mode === 'manual') {
|
||||
mergedAnimeIds = mergeAnimeRecordsInTransaction(db, animeId, others).mergedAnimeIds;
|
||||
} else {
|
||||
// Keep the entry the user already sees; the newcomer is the transient
|
||||
// "Show Season 3" row that a fresh season folder just created.
|
||||
survivor = others[0]!;
|
||||
mergedAnimeIds = mergeAnimeRecordsInTransaction(db, survivor, [
|
||||
animeId,
|
||||
...others.slice(1),
|
||||
]).mergedAnimeIds;
|
||||
}
|
||||
}
|
||||
|
||||
const updatedAt = toDbTimestamp(nowMs());
|
||||
if (options.mode === 'manual') {
|
||||
db.prepare(
|
||||
`UPDATE imm_anime
|
||||
SET media_kind = 'live_action',
|
||||
tmdb_id = ?,
|
||||
tmdb_type = ?,
|
||||
anilist_id = NULL,
|
||||
title_romaji = NULL,
|
||||
title_english = ?,
|
||||
title_native = ?,
|
||||
episodes_total = ?,
|
||||
description = ?,
|
||||
LAST_UPDATE_DATE = ?
|
||||
WHERE anime_id = ?`,
|
||||
).run(
|
||||
input.tmdbId,
|
||||
input.tmdbType,
|
||||
input.titleEnglish,
|
||||
input.titleNative,
|
||||
input.episodesTotal,
|
||||
input.description,
|
||||
updatedAt,
|
||||
survivor,
|
||||
);
|
||||
} else {
|
||||
db.prepare(
|
||||
`UPDATE imm_anime
|
||||
SET media_kind = 'live_action',
|
||||
tmdb_id = ?,
|
||||
tmdb_type = ?,
|
||||
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(
|
||||
input.tmdbId,
|
||||
input.tmdbType,
|
||||
input.titleEnglish,
|
||||
input.titleNative,
|
||||
input.episodesTotal,
|
||||
input.description,
|
||||
updatedAt,
|
||||
survivor,
|
||||
);
|
||||
}
|
||||
recomputeLifetimeAnimeAggregatesInTransaction(db);
|
||||
return { animeId: survivor, mergedAnimeIds };
|
||||
}
|
||||
|
||||
/** The TMDB link of the live-action entry a video belongs to, if any. */
|
||||
export function getVideoTmdbLink(db: DatabaseSync, videoId: number): VideoTmdbLink | null {
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT a.anime_id AS animeId, a.tmdb_id AS tmdbId, a.tmdb_type AS tmdbType
|
||||
FROM imm_videos v
|
||||
JOIN imm_anime a ON a.anime_id = v.anime_id
|
||||
WHERE v.video_id = ?
|
||||
AND a.media_kind = 'live_action'
|
||||
AND a.tmdb_id IS NOT NULL
|
||||
AND a.tmdb_type IN ('tv', 'movie')`,
|
||||
)
|
||||
.get(videoId) as VideoTmdbLink | undefined;
|
||||
return row ? { animeId: row.animeId, tmdbId: row.tmdbId, tmdbType: row.tmdbType } : null;
|
||||
}
|
||||
@@ -35,6 +35,9 @@ export function getAnimeLibrary(db: DatabaseSync): AnimeLibraryRow[] {
|
||||
a.canonical_title AS canonicalTitle,
|
||||
a.media_kind AS mediaKind,
|
||||
a.anilist_id AS anilistId,
|
||||
a.media_kind AS mediaKind,
|
||||
a.tmdb_id AS tmdbId,
|
||||
a.tmdb_type AS tmdbType,
|
||||
COALESCE(lm.total_sessions, 0) AS totalSessions,
|
||||
COALESCE(lm.total_active_ms, 0) AS totalActiveMs,
|
||||
COALESCE(lm.total_cards, 0) AS totalCards,
|
||||
@@ -66,6 +69,9 @@ export function getAnimeDetail(db: DatabaseSync, animeId: number): AnimeDetailRo
|
||||
a.canonical_title AS canonicalTitle,
|
||||
a.media_kind AS mediaKind,
|
||||
a.anilist_id AS anilistId,
|
||||
a.media_kind AS mediaKind,
|
||||
a.tmdb_id AS tmdbId,
|
||||
a.tmdb_type AS tmdbType,
|
||||
a.title_romaji AS titleRomaji,
|
||||
a.title_english AS titleEnglish,
|
||||
a.title_native AS titleNative,
|
||||
|
||||
@@ -331,6 +331,29 @@ export async function cleanupVocabularyStats(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the cached art of every episode in a library entry. Used when a manual
|
||||
* relink points at a title with no artwork, so the previous link's cover does
|
||||
* not keep standing in for it.
|
||||
*/
|
||||
export function clearAnimeCoverArt(db: DatabaseSync, animeId: number): void {
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT m.cover_blob_hash AS coverBlobHash
|
||||
FROM imm_media_art m
|
||||
JOIN imm_videos v ON v.video_id = m.video_id
|
||||
WHERE v.anime_id = ?`,
|
||||
)
|
||||
.all(animeId) as Array<{ coverBlobHash: string | null }>;
|
||||
if (rows.length === 0) return;
|
||||
db.prepare(
|
||||
'DELETE FROM imm_media_art WHERE video_id IN (SELECT video_id FROM imm_videos WHERE anime_id = ?)',
|
||||
).run(animeId);
|
||||
for (const hash of new Set(rows.map((row) => row.coverBlobHash))) {
|
||||
cleanupUnusedCoverArtBlobHash(db, hash);
|
||||
}
|
||||
}
|
||||
|
||||
export function upsertCoverArt(
|
||||
db: DatabaseSync,
|
||||
videoId: number,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { MediaKind } from '../../../shared/media-kind';
|
||||
import { sameTitleNamespaceSql, type MediaKind } from '../../../shared/media-kind';
|
||||
import { createHash } from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import { parseMediaInfo } from '../../../jimaku/utils';
|
||||
@@ -591,14 +591,19 @@ export function getOrCreateAnimeRecord(db: DatabaseSync, input: AnimeRecordInput
|
||||
anime_id: number;
|
||||
} | null)
|
||||
: null;
|
||||
// Title lookups stay inside the kind's namespace: a parsed filename may land
|
||||
// on a TMDB-linked live-action row, but never on a YouTube channel.
|
||||
const byNormalizedTitle = db
|
||||
.prepare('SELECT anime_id FROM imm_anime WHERE normalized_title_key = ? AND media_kind = ?')
|
||||
.prepare(
|
||||
`SELECT anime_id FROM imm_anime
|
||||
WHERE normalized_title_key = ? AND ${sameTitleNamespaceSql()}`,
|
||||
)
|
||||
.get(normalizedTitleKey, mediaKind) as { anime_id: number } | null;
|
||||
const byTitleAlias = db
|
||||
.prepare(
|
||||
`SELECT a.anime_id FROM imm_anime_title_aliases AS alias
|
||||
JOIN imm_anime AS a ON a.anime_id = alias.anime_id
|
||||
WHERE alias.normalized_title_key = ? AND a.media_kind = ?`,
|
||||
WHERE alias.normalized_title_key = ? AND ${sameTitleNamespaceSql('a.media_kind')}`,
|
||||
)
|
||||
.get(normalizedTitleKey, mediaKind) as { anime_id: number } | null;
|
||||
const existing = byAnilistId ?? byNormalizedTitle ?? byTitleAlias;
|
||||
@@ -611,7 +616,11 @@ export function getOrCreateAnimeRecord(db: DatabaseSync, input: AnimeRecordInput
|
||||
UPDATE imm_anime
|
||||
SET
|
||||
canonical_title = COALESCE(NULLIF(?, ''), canonical_title),
|
||||
anilist_id = CASE WHEN ? = 'youtube' THEN NULL ELSE COALESCE(?, anilist_id) END,
|
||||
anilist_id = CASE
|
||||
WHEN ? = 'youtube' THEN NULL
|
||||
WHEN tmdb_id IS NOT NULL THEN anilist_id
|
||||
ELSE COALESCE(?, anilist_id)
|
||||
END,
|
||||
title_romaji = COALESCE(?, title_romaji),
|
||||
title_english = COALESCE(?, title_english),
|
||||
title_native = COALESCE(?, title_native),
|
||||
@@ -889,13 +898,19 @@ function migrateLegacyAnimeMetadata(db: DatabaseSync): void {
|
||||
}
|
||||
}
|
||||
|
||||
// SQLite cannot drop a table-level UNIQUE constraint. Rebuild with IDs intact
|
||||
// and foreign keys disabled so dependent history and manual assignments survive.
|
||||
function migrateAnimeTitleUniqueness(db: DatabaseSync): void {
|
||||
// SQLite cannot drop a table-level UNIQUE constraint or a column CHECK.
|
||||
// Rebuild with IDs intact and foreign keys disabled so dependent history and
|
||||
// manual assignments survive. Two shapes need it: the original
|
||||
// `normalized_title_key UNIQUE`, and the v0.19.6 `media_kind` column whose
|
||||
// CHECK only allowed 'anime' and 'youtube'.
|
||||
const LEGACY_TITLE_UNIQUE_RE = /normalized_title_key TEXT NOT NULL UNIQUE/i;
|
||||
const LEGACY_MEDIA_KIND_CHECK_RE = /\s*CHECK\s*\(\s*media_kind IN \('anime',\s*'youtube'\)\s*\)/i;
|
||||
|
||||
function migrateAnimeTableConstraints(db: DatabaseSync): void {
|
||||
const schema = db.prepare("SELECT sql FROM sqlite_master WHERE name = 'imm_anime'").get() as {
|
||||
sql: string;
|
||||
};
|
||||
if (/normalized_title_key TEXT NOT NULL UNIQUE/i.test(schema.sql)) {
|
||||
if (LEGACY_TITLE_UNIQUE_RE.test(schema.sql) || LEGACY_MEDIA_KIND_CHECK_RE.test(schema.sql)) {
|
||||
const foreignKeys = db.prepare('PRAGMA foreign_keys').get() as { foreign_keys: number };
|
||||
const sequence = db
|
||||
.prepare("SELECT seq FROM sqlite_sequence WHERE name = 'imm_anime'")
|
||||
@@ -909,10 +924,8 @@ function migrateAnimeTitleUniqueness(db: DatabaseSync): void {
|
||||
/CREATE TABLE (?:IF NOT EXISTS )?["`]?imm_anime["`]?/i,
|
||||
'CREATE TABLE imm_anime_new',
|
||||
)
|
||||
.replace(
|
||||
/normalized_title_key TEXT NOT NULL UNIQUE/i,
|
||||
'normalized_title_key TEXT NOT NULL',
|
||||
),
|
||||
.replace(LEGACY_TITLE_UNIQUE_RE, 'normalized_title_key TEXT NOT NULL')
|
||||
.replace(LEGACY_MEDIA_KIND_CHECK_RE, ''),
|
||||
);
|
||||
db.exec(`INSERT INTO imm_anime_new SELECT * FROM imm_anime;
|
||||
DROP TABLE imm_anime;
|
||||
@@ -930,8 +943,11 @@ function migrateAnimeTitleUniqueness(db: DatabaseSync): void {
|
||||
db.exec(`PRAGMA foreign_keys = ${foreignKeys.foreign_keys}`);
|
||||
}
|
||||
}
|
||||
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_anime_kind_title
|
||||
ON imm_anime(media_kind, normalized_title_key)`);
|
||||
// v0.19.6 scoped titles per kind; anime and live-action now share one
|
||||
// namespace (an entry moves between them when relinked), YouTube is separate.
|
||||
db.exec(`DROP INDEX IF EXISTS idx_anime_kind_title;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_anime_namespace_title
|
||||
ON imm_anime((media_kind = 'youtube'), normalized_title_key)`);
|
||||
}
|
||||
|
||||
// Older builds can create channel rows with the default anime kind even after
|
||||
@@ -995,17 +1011,20 @@ export function ensureSchema(db: DatabaseSync): void {
|
||||
title_native TEXT,
|
||||
episodes_total INTEGER,
|
||||
description TEXT,
|
||||
media_kind TEXT NOT NULL DEFAULT 'anime',
|
||||
tmdb_id INTEGER,
|
||||
tmdb_type TEXT,
|
||||
metadata_json TEXT,
|
||||
CREATED_DATE TEXT,
|
||||
LAST_UPDATE_DATE TEXT
|
||||
);
|
||||
`);
|
||||
addColumnIfMissing(
|
||||
db,
|
||||
'imm_anime',
|
||||
'media_kind',
|
||||
"TEXT NOT NULL DEFAULT 'anime' CHECK(media_kind IN ('anime', 'youtube'))",
|
||||
);
|
||||
// Schema 26: media_kind separates anime, live-action (TMDB link) and YouTube
|
||||
// channel entries. Kinds are validated in code, not by a CHECK constraint,
|
||||
// so adding one later does not need a table rebuild.
|
||||
addColumnIfMissing(db, 'imm_anime', 'media_kind', "TEXT NOT NULL DEFAULT 'anime'");
|
||||
addColumnIfMissing(db, 'imm_anime', 'tmdb_id', 'INTEGER');
|
||||
addColumnIfMissing(db, 'imm_anime', 'tmdb_type', 'TEXT');
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS imm_videos(
|
||||
video_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -1549,7 +1568,7 @@ export function ensureSchema(db: DatabaseSync): void {
|
||||
);
|
||||
}
|
||||
|
||||
migrateAnimeTitleUniqueness(db);
|
||||
migrateAnimeTableConstraints(db);
|
||||
classifyYoutubeChannels(db);
|
||||
migrateSessionEventTimestampsToText(db);
|
||||
|
||||
@@ -1565,6 +1584,10 @@ export function ensureSchema(db: DatabaseSync): void {
|
||||
CREATE INDEX IF NOT EXISTS idx_anime_anilist_id
|
||||
ON imm_anime(anilist_id)
|
||||
`);
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_anime_tmdb_id
|
||||
ON imm_anime(tmdb_id, tmdb_type)
|
||||
`);
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_videos_anime_id
|
||||
ON imm_videos(anime_id)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { MediaKind } from '../../../shared/media-kind';
|
||||
import type { MediaKind, TmdbMediaType } from '../../../shared/media-kind';
|
||||
|
||||
export const SCHEMA_VERSION = 25;
|
||||
// 26: live-action entries (TMDB link) and YouTube channels share the media_kind column.
|
||||
export const SCHEMA_VERSION = 26;
|
||||
export const DEFAULT_QUEUE_CAP = 1_000;
|
||||
export const DEFAULT_BATCH_SIZE = 25;
|
||||
export const DEFAULT_FLUSH_INTERVAL_MS = 500;
|
||||
@@ -524,6 +525,8 @@ export interface AnimeLibraryRow {
|
||||
animeId: number;
|
||||
canonicalTitle: string;
|
||||
anilistId: number | null;
|
||||
tmdbId: number | null;
|
||||
tmdbType: TmdbMediaType | null;
|
||||
totalSessions: number;
|
||||
totalActiveMs: number;
|
||||
totalCards: number;
|
||||
@@ -538,6 +541,8 @@ export interface AnimeDetailRow {
|
||||
animeId: number;
|
||||
canonicalTitle: string;
|
||||
anilistId: number | null;
|
||||
tmdbId: number | null;
|
||||
tmdbType: TmdbMediaType | null;
|
||||
titleRomaji: string | null;
|
||||
titleEnglish: string | null;
|
||||
titleNative: string | null;
|
||||
|
||||
@@ -81,7 +81,7 @@ test('schema 23 channel migration preserves history and manual assignments and i
|
||||
const history = getAnimeLibrary(db);
|
||||
// Reproduce the previous schema, including its lack of a media kind column.
|
||||
db.exec(
|
||||
'DROP INDEX idx_anime_kind_title; ALTER TABLE imm_anime DROP COLUMN media_kind; DELETE FROM imm_schema_version; INSERT INTO imm_schema_version VALUES (23, 0)',
|
||||
'DROP INDEX idx_anime_namespace_title; ALTER TABLE imm_anime DROP COLUMN media_kind; DELETE FROM imm_schema_version; INSERT INTO imm_schema_version VALUES (23, 0)',
|
||||
);
|
||||
ensureSchema(db);
|
||||
ensureSchema(db);
|
||||
@@ -240,6 +240,52 @@ test('title identity and aliases never cross media kinds', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('anime title lookups land on a same-named live-action entry but never on a channel', () => {
|
||||
const db = new Database(':memory:');
|
||||
try {
|
||||
ensureSchema(db);
|
||||
const dramaId = createAnime(db, 'Hanzawa Naoki');
|
||||
db.prepare(
|
||||
"UPDATE imm_anime SET media_kind = 'live_action', tmdb_id = 61222, tmdb_type = 'tv' WHERE anime_id = ?",
|
||||
).run(dramaId);
|
||||
// A later season folder parses to the same title with the default anime
|
||||
// kind and must join the TMDB-linked entry rather than duplicate it.
|
||||
assert.equal(
|
||||
getOrCreateAnimeRecord(db, {
|
||||
parsedTitle: 'Hanzawa Naoki',
|
||||
canonicalTitle: 'Hanzawa Naoki',
|
||||
anilistId: 99,
|
||||
titleRomaji: null,
|
||||
titleEnglish: null,
|
||||
titleNative: null,
|
||||
metadataJson: null,
|
||||
}),
|
||||
dramaId,
|
||||
);
|
||||
const row = db
|
||||
.prepare('SELECT media_kind, anilist_id, tmdb_id FROM imm_anime WHERE anime_id = ?')
|
||||
.get(dramaId) as { media_kind: string; anilist_id: number | null; tmdb_id: number };
|
||||
assert.equal(row.media_kind, 'live_action');
|
||||
assert.equal(row.anilist_id, null);
|
||||
assert.equal(row.tmdb_id, 61222);
|
||||
assert.notEqual(
|
||||
getOrCreateAnimeRecord(db, {
|
||||
mediaKind: 'youtube',
|
||||
parsedTitle: 'Hanzawa Naoki',
|
||||
canonicalTitle: 'Hanzawa Naoki',
|
||||
anilistId: null,
|
||||
titleRomaji: null,
|
||||
titleEnglish: null,
|
||||
titleNative: null,
|
||||
metadataJson: null,
|
||||
}),
|
||||
dramaId,
|
||||
);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('schema 24 title constraint migration preserves referenced data', () => {
|
||||
const db = new Database(':memory:');
|
||||
try {
|
||||
@@ -273,7 +319,9 @@ test('schema 24 title constraint migration preserves referenced data', () => {
|
||||
title_romaji TEXT, title_english TEXT, title_native TEXT, episodes_total INTEGER,
|
||||
description TEXT, metadata_json TEXT, CREATED_DATE TEXT, LAST_UPDATE_DATE TEXT,
|
||||
media_kind TEXT NOT NULL DEFAULT 'anime' CHECK(media_kind IN ('anime', 'youtube')));
|
||||
INSERT INTO imm_anime SELECT * FROM old_anime;
|
||||
INSERT INTO imm_anime SELECT anime_id, normalized_title_key, canonical_title, anilist_id,
|
||||
title_romaji, title_english, title_native, episodes_total, description, metadata_json,
|
||||
CREATED_DATE, LAST_UPDATE_DATE, media_kind FROM old_anime;
|
||||
DROP TABLE old_anime;
|
||||
DELETE FROM imm_schema_version;
|
||||
INSERT INTO imm_schema_version VALUES (24, 0);
|
||||
@@ -281,6 +329,10 @@ test('schema 24 title constraint migration preserves referenced data', () => {
|
||||
ensureSchema(db);
|
||||
ensureSchema(db);
|
||||
assert.deepEqual(db.prepare('PRAGMA foreign_key_check').all(), []);
|
||||
// The v0.19.6 CHECK only allowed anime and youtube; live-action must fit now.
|
||||
db.prepare(
|
||||
"INSERT INTO imm_anime(normalized_title_key, canonical_title, media_kind, tmdb_id, tmdb_type) VALUES ('drama', 'Drama', 'live_action', 1, 'tv')",
|
||||
).run();
|
||||
assert.equal(
|
||||
(db.prepare('PRAGMA foreign_keys').get() as { foreign_keys: number }).foreign_keys,
|
||||
1,
|
||||
|
||||
Reference in New Issue
Block a user