Files
netfelix-audio-fix/server/services/rescan.ts
T
felixfoertsch 9d65dd12be
Build and Push Docker Image / build (push) Successful in 2m10s
pipeline ux: actionable errors, inbox sorting, danger stop buttons, overlapping checkmarks
- 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>
2026-04-21 08:58:08 +02:00

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;
}