fbfd492e18
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
113 lines
4.3 KiB
TypeScript
113 lines
4.3 KiB
TypeScript
import type { Database } from "bun:sqlite";
|
|
import type { ParsedPath } from "./path-parser";
|
|
import type { ProbeResult } from "./probe";
|
|
import { guessOriginalLanguage } from "./language-utils";
|
|
|
|
export interface UpsertResult {
|
|
itemId: number;
|
|
origLang: string | null;
|
|
origLangSource: string | null;
|
|
needsReview: number;
|
|
isNew: boolean;
|
|
}
|
|
|
|
/**
|
|
* Upsert a scanned item (metadata + streams + review_plan) in one transaction.
|
|
* Driven by filesystem path + ffprobe output.
|
|
*/
|
|
export function upsertScannedItem(
|
|
db: Database,
|
|
filePath: string,
|
|
parsed: ParsedPath,
|
|
probe: ProbeResult,
|
|
): UpsertResult {
|
|
// Derive series_key for episode grouping
|
|
const seriesKey = parsed.seriesName
|
|
? `${parsed.seriesName}|${parsed.year ?? ""}`
|
|
: null;
|
|
|
|
// Preserve manual language overrides
|
|
const existing = db
|
|
.prepare("SELECT id, original_language, orig_lang_source FROM media_items WHERE file_path = ?")
|
|
.get(filePath) as { id: number; original_language: string | null; orig_lang_source: string | null } | undefined;
|
|
const hasManualOverride = existing?.orig_lang_source === "manual";
|
|
|
|
// Guess language from default audio track
|
|
const audioStreams = probe.streams
|
|
.filter((s) => s.type === "Audio")
|
|
.map((s) => ({ language: s.language, title: s.title, isDefault: s.isDefault }));
|
|
const probeGuess = guessOriginalLanguage(audioStreams);
|
|
|
|
const origLang = hasManualOverride ? existing!.original_language : probeGuess;
|
|
const origLangSource: string | null = hasManualOverride ? "manual" : (probeGuess ? "probe" : null);
|
|
const needsReview = origLang ? (hasManualOverride ? 0 : 1) : 1;
|
|
|
|
const result: UpsertResult = { itemId: -1, origLang, origLangSource, needsReview, isNew: !existing };
|
|
|
|
db.transaction(() => {
|
|
db.prepare(`
|
|
INSERT INTO media_items (
|
|
file_path, type, name, series_name, series_key,
|
|
season_number, episode_number, year, file_size, container,
|
|
duration_seconds, original_language, orig_lang_source, needs_review,
|
|
imdb_id, tmdb_id, tvdb_id,
|
|
scan_status, last_scanned_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'scanned', datetime('now'))
|
|
ON CONFLICT(file_path) DO UPDATE SET
|
|
type = excluded.type, name = excluded.name,
|
|
series_name = excluded.series_name, series_key = excluded.series_key,
|
|
season_number = excluded.season_number, episode_number = excluded.episode_number,
|
|
year = excluded.year, file_size = excluded.file_size, container = excluded.container,
|
|
duration_seconds = excluded.duration_seconds,
|
|
original_language = excluded.original_language, orig_lang_source = excluded.orig_lang_source,
|
|
needs_review = excluded.needs_review,
|
|
imdb_id = excluded.imdb_id, tmdb_id = excluded.tmdb_id, tvdb_id = excluded.tvdb_id,
|
|
scan_status = 'scanned', last_scanned_at = datetime('now')
|
|
`).run(
|
|
filePath, parsed.type, parsed.name, parsed.seriesName, seriesKey,
|
|
parsed.seasonNumber, parsed.episodeNumber, parsed.year,
|
|
probe.fileSize, parsed.container, probe.durationSeconds,
|
|
origLang, origLangSource, needsReview,
|
|
parsed.imdbId, parsed.tmdbId, parsed.tvdbId,
|
|
);
|
|
|
|
const row = db.prepare("SELECT id FROM media_items WHERE file_path = ?").get(filePath) as { id: number };
|
|
const itemId = row.id;
|
|
result.itemId = itemId;
|
|
|
|
// Replace streams
|
|
db.prepare("DELETE FROM media_streams WHERE item_id = ?").run(itemId);
|
|
const ins = db.prepare(`
|
|
INSERT INTO media_streams (
|
|
item_id, stream_index, type, codec, profile, language, title,
|
|
is_default, is_forced, is_hearing_impaired,
|
|
channels, channel_layout, bit_rate, sample_rate, bit_depth
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`);
|
|
for (const s of probe.streams) {
|
|
ins.run(
|
|
itemId, s.streamIndex, s.type, s.codec, s.profile, s.language, s.title,
|
|
s.isDefault, s.isForced, s.isHearingImpaired,
|
|
s.channels, s.channelLayout, s.bitRate, s.sampleRate, s.bitDepth,
|
|
);
|
|
}
|
|
|
|
// Stub review_plan (don't disturb existing non-error plans)
|
|
db.prepare(`
|
|
INSERT INTO review_plans (item_id, status, is_noop, sorted)
|
|
VALUES (?, 'pending', 0, 0)
|
|
ON CONFLICT(item_id) DO UPDATE SET
|
|
status = CASE
|
|
WHEN review_plans.status = 'error' THEN 'pending'
|
|
ELSE review_plans.status
|
|
END,
|
|
sorted = CASE
|
|
WHEN review_plans.status = 'error' THEN 0
|
|
ELSE review_plans.sorted
|
|
END
|
|
`).run(itemId);
|
|
})();
|
|
|
|
return result;
|
|
}
|