9d65dd12be
Build and Push Docker Image / build (push) Successful in 2m10s
- clickable error count in header shows file names + error messages - inbox sort dropdown (scan time / name, asc / desc) - inbox movies no longer minimal (show available badges) - stop buttons use solid danger style, descriptive labels (Stop Scan, Stop Job, Stop Sorting) - double checkmarks overlap like WhatsApp read receipts - processInbox logs start/completion to stdout for Docker visibility - fix byTitle in language-resolver test, bump to 2026.04.21.1 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
139 lines
4.4 KiB
TypeScript
139 lines
4.4 KiB
TypeScript
import type { Database } from "bun:sqlite";
|
|
import { guessOriginalLanguage } from "./language-utils";
|
|
import type { ParsedPath } from "./path-parser";
|
|
import type { ProbeResult } from "./probe";
|
|
|
|
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;
|
|
}
|