mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-14 13:55:55 -07:00
6311838ef6
- Recompute anime aggregates from retained lifetime media summaries - Prevent sequel title evidence and AniList conflicts from misattributing entries
294 lines
9.9 KiB
TypeScript
294 lines
9.9 KiB
TypeScript
import type { DatabaseSync } from './sqlite';
|
|
import { recomputeLifetimeAnimeAggregatesInTransaction } from './lifetime';
|
|
import { toDbTimestamp } from './query-shared';
|
|
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';
|
|
|
|
export interface AnimeMergeSummary {
|
|
/** Library entry that owns every moved episode once the merge finishes. */
|
|
survivingAnimeId: number;
|
|
/** Entries that were folded into the survivor and deleted. */
|
|
mergedAnimeIds: number[];
|
|
movedVideos: number;
|
|
}
|
|
|
|
export interface VideoMoveSummary {
|
|
targetAnimeId: number;
|
|
/** Previous owner, or null when the episode had no library entry yet. */
|
|
previousAnimeId: number | null;
|
|
/** True when the previous owner was left empty and pruned. */
|
|
removedPreviousAnime: boolean;
|
|
}
|
|
|
|
interface AnimeMetadataRow {
|
|
normalized_title_key: string;
|
|
anilist_id: number | null;
|
|
title_romaji: string | null;
|
|
title_english: string | null;
|
|
title_native: string | null;
|
|
episodes_total: number | null;
|
|
description: string | null;
|
|
}
|
|
|
|
function emptyMergeSummary(survivingAnimeId: number): AnimeMergeSummary {
|
|
return { survivingAnimeId, mergedAnimeIds: [], movedVideos: 0 };
|
|
}
|
|
|
|
function runInTransaction<T>(db: DatabaseSync, work: () => T): T {
|
|
db.exec('BEGIN IMMEDIATE');
|
|
try {
|
|
const result = work();
|
|
db.exec('COMMIT');
|
|
return result;
|
|
} catch (error) {
|
|
db.exec('ROLLBACK');
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function readAnimeMetadata(db: DatabaseSync, animeId: number): AnimeMetadataRow | null {
|
|
return (db
|
|
.prepare(
|
|
`
|
|
SELECT normalized_title_key, anilist_id, title_romaji, title_english, title_native, episodes_total, description
|
|
FROM imm_anime
|
|
WHERE anime_id = ?
|
|
`,
|
|
)
|
|
.get(animeId) ?? null) as AnimeMetadataRow | null;
|
|
}
|
|
|
|
function animeExists(db: DatabaseSync, animeId: number): boolean {
|
|
return Boolean(db.prepare('SELECT 1 FROM imm_anime WHERE anime_id = ?').get(animeId));
|
|
}
|
|
|
|
function hasAnimeReferences(db: DatabaseSync, animeId: number): boolean {
|
|
const row = db
|
|
.prepare(
|
|
`
|
|
SELECT 1 AS found
|
|
WHERE EXISTS (SELECT 1 FROM imm_videos WHERE anime_id = ?)
|
|
OR EXISTS (SELECT 1 FROM imm_subtitle_lines WHERE anime_id = ?)
|
|
`,
|
|
)
|
|
.get(animeId, animeId) as { found: number } | null;
|
|
return Boolean(row);
|
|
}
|
|
|
|
/**
|
|
* Distinct explicit seasons behind a library entry. Videos with no parsed
|
|
* season are ignored, so an entry built from `Show - 03.mkv` style filenames
|
|
* reports an empty set rather than a bogus season.
|
|
*/
|
|
export function getParsedSeasonsForAnime(db: DatabaseSync, animeId: number): Set<number> {
|
|
const rows = db
|
|
.prepare(
|
|
`
|
|
SELECT DISTINCT parsed_season AS season
|
|
FROM imm_videos
|
|
WHERE anime_id = ?
|
|
AND parsed_season IS NOT NULL
|
|
AND parsed_season > 0
|
|
`,
|
|
)
|
|
.all(animeId) as Array<{ season: number }>;
|
|
return new Set(rows.map((row) => row.season));
|
|
}
|
|
|
|
/**
|
|
* Two entries are safe to fold together when neither spans more than one
|
|
* explicit season and they do not disagree about which season that is. A
|
|
* seasonless entry is compatible with anything single-season: those are the
|
|
* `Show - 03.mkv` vs `Show.S01E03.mkv` splits that produce duplicate cards.
|
|
*/
|
|
export function animeSeasonsAreMergeCompatible(a: Set<number>, b: Set<number>): boolean {
|
|
if (a.size > 1 || b.size > 1) return false;
|
|
if (a.size === 0 || b.size === 0) return true;
|
|
return [...a][0] === [...b][0];
|
|
}
|
|
|
|
/**
|
|
* Fill in whatever the target is missing from a source row that is on its way
|
|
* out. Must run after the source row is deleted: imm_anime.anilist_id is
|
|
* UNIQUE, so the two rows cannot hold the same id at once.
|
|
*/
|
|
function absorbAnimeMetadata(
|
|
db: DatabaseSync,
|
|
targetAnimeId: number,
|
|
source: AnimeMetadataRow | null,
|
|
updatedAt: string,
|
|
): void {
|
|
if (!source) return;
|
|
db.prepare(
|
|
`
|
|
UPDATE imm_anime
|
|
SET
|
|
anilist_id = COALESCE(anilist_id, ?),
|
|
title_romaji = COALESCE(title_romaji, ?),
|
|
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(
|
|
source.anilist_id,
|
|
source.title_romaji,
|
|
source.title_english,
|
|
source.title_native,
|
|
source.episodes_total,
|
|
source.description,
|
|
updatedAt,
|
|
targetAnimeId,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Fold `sourceAnimeIds` into `targetAnimeId`: every episode and subtitle line
|
|
* is repointed, metadata the target is missing is inherited from the sources,
|
|
* and the emptied source rows are deleted.
|
|
*
|
|
* Assumes the caller already holds a write transaction and refreshes the
|
|
* per-anime lifetime aggregates afterwards; use {@link mergeAnimeRecords}
|
|
* otherwise.
|
|
*/
|
|
export function mergeAnimeRecordsInTransaction(
|
|
db: DatabaseSync,
|
|
targetAnimeId: number,
|
|
sourceAnimeIds: number[],
|
|
): AnimeMergeSummary {
|
|
const summary = emptyMergeSummary(targetAnimeId);
|
|
if (!animeExists(db, targetAnimeId)) {
|
|
return summary;
|
|
}
|
|
|
|
const updatedAt = toDbTimestamp(nowMs());
|
|
const sourceVideosStmt = db.prepare(
|
|
'SELECT video_id AS videoId FROM imm_videos WHERE anime_id = ?',
|
|
);
|
|
const moveVideosStmt = db.prepare(
|
|
'UPDATE imm_videos SET anime_id = ?, LAST_UPDATE_DATE = ? WHERE anime_id = ?',
|
|
);
|
|
// Repointed per video rather than by anime_id: lines recorded before the
|
|
// async title parse assigns the link are stored with a NULL anime_id, and
|
|
// matching on the source id would strand them unattributed.
|
|
const moveLinesStmt = db.prepare(
|
|
'UPDATE imm_subtitle_lines SET anime_id = ?, LAST_UPDATE_DATE = ? WHERE video_id = ?',
|
|
);
|
|
const dropLifetimeStmt = db.prepare('DELETE FROM imm_lifetime_anime WHERE anime_id = ?');
|
|
const sourceAliasesStmt = db.prepare(
|
|
'SELECT normalized_title_key AS normalizedTitleKey FROM imm_anime_title_aliases WHERE anime_id = ?',
|
|
);
|
|
const upsertAliasStmt = db.prepare(
|
|
`INSERT INTO imm_anime_title_aliases(normalized_title_key, anime_id, CREATED_DATE, LAST_UPDATE_DATE)
|
|
VALUES (?, ?, ?, ?)
|
|
ON CONFLICT(normalized_title_key) DO UPDATE SET
|
|
anime_id = excluded.anime_id,
|
|
LAST_UPDATE_DATE = excluded.LAST_UPDATE_DATE`,
|
|
);
|
|
const dropSourceAliasesStmt = db.prepare(
|
|
'DELETE FROM imm_anime_title_aliases WHERE anime_id = ?',
|
|
);
|
|
const dropAnimeStmt = db.prepare('DELETE FROM imm_anime WHERE anime_id = ?');
|
|
|
|
for (const sourceAnimeId of new Set(sourceAnimeIds)) {
|
|
if (sourceAnimeId === targetAnimeId || !animeExists(db, sourceAnimeId)) {
|
|
continue;
|
|
}
|
|
|
|
const sourceMetadata = readAnimeMetadata(db, sourceAnimeId);
|
|
const sourceAliases = sourceAliasesStmt.all(sourceAnimeId) as Array<{
|
|
normalizedTitleKey: string;
|
|
}>;
|
|
const sourceVideoIds = (sourceVideosStmt.all(sourceAnimeId) as Array<{ videoId: number }>).map(
|
|
(row) => row.videoId,
|
|
);
|
|
const moved = moveVideosStmt.run(targetAnimeId, updatedAt, sourceAnimeId) as {
|
|
changes: number;
|
|
};
|
|
for (const videoId of sourceVideoIds) {
|
|
moveLinesStmt.run(targetAnimeId, updatedAt, videoId);
|
|
}
|
|
dropSourceAliasesStmt.run(sourceAnimeId);
|
|
for (const alias of [
|
|
...(sourceMetadata ? [sourceMetadata.normalized_title_key] : []),
|
|
...sourceAliases.map((row) => row.normalizedTitleKey),
|
|
]) {
|
|
upsertAliasStmt.run(alias, targetAnimeId, updatedAt, updatedAt);
|
|
}
|
|
dropLifetimeStmt.run(sourceAnimeId);
|
|
dropAnimeStmt.run(sourceAnimeId);
|
|
absorbAnimeMetadata(db, targetAnimeId, sourceMetadata, updatedAt);
|
|
|
|
summary.mergedAnimeIds.push(sourceAnimeId);
|
|
summary.movedVideos += moved.changes;
|
|
}
|
|
|
|
return summary;
|
|
}
|
|
|
|
export function mergeAnimeRecords(
|
|
db: DatabaseSync,
|
|
targetAnimeId: number,
|
|
sourceAnimeIds: number[],
|
|
): AnimeMergeSummary {
|
|
return runInTransaction(db, () => {
|
|
const summary = mergeAnimeRecordsInTransaction(db, targetAnimeId, sourceAnimeIds);
|
|
if (summary.mergedAnimeIds.length > 0) {
|
|
recomputeLifetimeAnimeAggregatesInTransaction(db);
|
|
}
|
|
return summary;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Move a single episode to another library entry, pruning the previous owner
|
|
* when it is left with nothing.
|
|
*/
|
|
export function moveVideoToAnime(
|
|
db: DatabaseSync,
|
|
videoId: number,
|
|
targetAnimeId: number,
|
|
): VideoMoveSummary {
|
|
return runInTransaction(db, () => {
|
|
const videoRow = db
|
|
.prepare('SELECT anime_id AS animeId FROM imm_videos WHERE video_id = ?')
|
|
.get(videoId) as { animeId: number | null } | null;
|
|
if (!videoRow || !animeExists(db, targetAnimeId)) {
|
|
throw new Error(UNKNOWN_MOVE_TARGET_MESSAGE);
|
|
}
|
|
|
|
const previousAnimeId = videoRow.animeId;
|
|
if (previousAnimeId === targetAnimeId) {
|
|
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_subtitle_lines SET anime_id = ?, LAST_UPDATE_DATE = ? WHERE video_id = ?',
|
|
).run(targetAnimeId, updatedAt, videoId);
|
|
|
|
let removedPreviousAnime = false;
|
|
if (previousAnimeId !== null && !hasAnimeReferences(db, previousAnimeId)) {
|
|
// The emptied entry's metadata is deliberately dropped rather than
|
|
// absorbed. A move says "this episode belongs elsewhere", not "these are
|
|
// the same show", and the entry being emptied is usually a mis-parse
|
|
// whose AniList link would be wrong for the target.
|
|
db.prepare('DELETE FROM imm_lifetime_anime WHERE anime_id = ?').run(previousAnimeId);
|
|
db.prepare('DELETE FROM imm_anime WHERE anime_id = ?').run(previousAnimeId);
|
|
removedPreviousAnime = true;
|
|
}
|
|
|
|
recomputeLifetimeAnimeAggregatesInTransaction(db);
|
|
return { targetAnimeId, previousAnimeId, removedPreviousAnime };
|
|
});
|
|
}
|