mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-19 05:16:27 -07:00
180 lines
5.6 KiB
TypeScript
180 lines
5.6 KiB
TypeScript
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,
|
|
);
|
|
}
|
|
if (mergedAnimeIds.length > 0) 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;
|
|
}
|