3341ceed14
Build and Push Docker Image / build (push) Successful in 1m13s
upsertJellyfinItem no longer runs analyzeItem or creates stream_decisions. it inserts a minimal review_plans stub (pending, unsorted). all analysis happens in processInbox. this means after scan, ALL items land in the inbox — the "needs action" count equals the inbox count until processing classifies them. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
202 lines
7.3 KiB
TypeScript
202 lines
7.3 KiB
TypeScript
import type { Database } from "bun:sqlite";
|
|
import type { JellyfinItem } from "../types";
|
|
import { extractOriginalLanguage, mapStream } from "./jellyfin";
|
|
|
|
export interface RescanConfig {
|
|
// Currently empty — scan is pure ingest. Kept as a type so callers don't
|
|
// need to change signature when we add scan-time options later.
|
|
}
|
|
|
|
export interface RescanResult {
|
|
itemId: number;
|
|
origLang: string | null;
|
|
origLangSource: string | null;
|
|
needsReview: number;
|
|
isNoop: boolean;
|
|
}
|
|
|
|
/**
|
|
* Upsert a single Jellyfin item (metadata + streams + review_plan + decisions)
|
|
* in one transaction. Shared by the full scan loop and the post-execute refresh.
|
|
*
|
|
* Returns the internal item id and a summary of what happened so callers can
|
|
* aggregate counters or emit SSE events.
|
|
*/
|
|
export async function upsertJellyfinItem(
|
|
db: Database,
|
|
jellyfinItem: JellyfinItem,
|
|
cfg: RescanConfig,
|
|
opts: { executed?: boolean; source?: "scan" | "webhook" } = {},
|
|
): Promise<RescanResult> {
|
|
const source = opts.source ?? "scan";
|
|
if (!jellyfinItem.Name || !jellyfinItem.Path) {
|
|
throw new Error(`Jellyfin item ${jellyfinItem.Id} missing Name or Path`);
|
|
}
|
|
const itemName: string = jellyfinItem.Name;
|
|
const itemPath: string = jellyfinItem.Path;
|
|
|
|
const providerIds = jellyfinItem.ProviderIds ?? {};
|
|
const seriesProviderIds = jellyfinItem.SeriesProviderIds ?? {};
|
|
const imdbId = providerIds.Imdb ?? null;
|
|
const tmdbId = providerIds.Tmdb ?? null;
|
|
const tvdbId =
|
|
jellyfinItem.Type === "Episode"
|
|
? (seriesProviderIds.Tvdb ?? providerIds.Tvdb ?? null)
|
|
: (providerIds.Tvdb ?? null);
|
|
|
|
// Preserve manual overrides: if the user has already pinned a language via
|
|
// /series/:key/language or /:id/language, scans must not blast it back to
|
|
// the audio-track guess. Read the prior row BEFORE running the guesses.
|
|
const existing = db
|
|
.prepare("SELECT original_language, orig_lang_source FROM media_items WHERE jellyfin_id = ?")
|
|
.get(jellyfinItem.Id) as { original_language: string | null; orig_lang_source: string | null } | undefined;
|
|
const hasManualOverride = existing?.orig_lang_source === "manual";
|
|
|
|
// See scan.ts for the "8 Mile got labelled Turkish" rationale. Jellyfin's
|
|
// first-audio-track guess is an unverified starting point.
|
|
const jellyfinGuess = extractOriginalLanguage(jellyfinItem);
|
|
let origLang: string | null = hasManualOverride ? (existing?.original_language ?? null) : jellyfinGuess;
|
|
let origLangSource: "jellyfin" | "manual" | null = hasManualOverride
|
|
? "manual"
|
|
: jellyfinGuess
|
|
? "jellyfin"
|
|
: null;
|
|
let needsReview = origLang ? 0 : 1;
|
|
const authoritative = hasManualOverride;
|
|
|
|
if (origLang && !authoritative && !needsReview) needsReview = 1;
|
|
|
|
const jellyfinRaw = JSON.stringify(jellyfinItem);
|
|
|
|
const result: RescanResult = {
|
|
itemId: -1,
|
|
origLang: null,
|
|
origLangSource: null,
|
|
needsReview: 1,
|
|
isNoop: false,
|
|
};
|
|
|
|
// One transaction per item keeps scan throughput high on SQLite — every
|
|
// INSERT/UPDATE would otherwise hit WAL independently.
|
|
db.transaction(() => {
|
|
const upsertItem = db.prepare(`
|
|
INSERT INTO media_items (
|
|
jellyfin_id, type, name, original_title, series_name, series_jellyfin_id,
|
|
season_number, episode_number, year, file_path, file_size, container,
|
|
runtime_ticks, date_last_refreshed,
|
|
original_language, orig_lang_source, needs_review,
|
|
imdb_id, tmdb_id, tvdb_id,
|
|
jellyfin_raw, external_raw,
|
|
scan_status, last_scanned_at, ingest_source${opts.executed ? ", last_executed_at" : ""}
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'scanned', datetime('now'), ?${opts.executed ? ", datetime('now')" : ""})
|
|
ON CONFLICT(jellyfin_id) DO UPDATE SET
|
|
type = excluded.type, name = excluded.name, original_title = excluded.original_title,
|
|
series_name = excluded.series_name, series_jellyfin_id = excluded.series_jellyfin_id,
|
|
season_number = excluded.season_number, episode_number = excluded.episode_number,
|
|
year = excluded.year, file_path = excluded.file_path,
|
|
file_size = excluded.file_size, container = excluded.container,
|
|
runtime_ticks = excluded.runtime_ticks, date_last_refreshed = excluded.date_last_refreshed,
|
|
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,
|
|
jellyfin_raw = excluded.jellyfin_raw, external_raw = excluded.external_raw,
|
|
scan_status = 'scanned', last_scanned_at = datetime('now'),
|
|
ingest_source = excluded.ingest_source
|
|
${opts.executed ? ", last_executed_at = datetime('now')" : ""}
|
|
`);
|
|
upsertItem.run(
|
|
jellyfinItem.Id,
|
|
jellyfinItem.Type === "Episode" ? "Episode" : "Movie",
|
|
itemName,
|
|
jellyfinItem.OriginalTitle ?? null,
|
|
jellyfinItem.SeriesName ?? null,
|
|
jellyfinItem.SeriesId ?? null,
|
|
jellyfinItem.ParentIndexNumber ?? null,
|
|
jellyfinItem.IndexNumber ?? null,
|
|
jellyfinItem.ProductionYear ?? null,
|
|
itemPath,
|
|
jellyfinItem.Size ?? null,
|
|
jellyfinItem.Container ?? null,
|
|
jellyfinItem.RunTimeTicks ?? null,
|
|
jellyfinItem.DateLastRefreshed ?? null,
|
|
origLang,
|
|
origLangSource,
|
|
needsReview,
|
|
imdbId,
|
|
tmdbId,
|
|
tvdbId,
|
|
jellyfinRaw,
|
|
null, // external_raw — no longer populated during scan
|
|
source,
|
|
);
|
|
|
|
const itemRow = db.prepare("SELECT id FROM media_items WHERE jellyfin_id = ?").get(jellyfinItem.Id) as {
|
|
id: number;
|
|
};
|
|
const itemId = itemRow.id;
|
|
result.itemId = itemId;
|
|
|
|
db.prepare("DELETE FROM media_streams WHERE item_id = ?").run(itemId);
|
|
const insertStream = db.prepare(`
|
|
INSERT INTO media_streams (
|
|
item_id, stream_index, type, codec, profile, language, language_display,
|
|
title, is_default, is_forced, is_hearing_impaired,
|
|
channels, channel_layout, bit_rate, sample_rate, bit_depth
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`);
|
|
for (const jStream of jellyfinItem.MediaStreams ?? []) {
|
|
if (jStream.IsExternal) continue;
|
|
const s = mapStream(jStream);
|
|
insertStream.run(
|
|
itemId,
|
|
s.stream_index,
|
|
s.type,
|
|
s.codec,
|
|
s.profile,
|
|
s.language,
|
|
s.language_display,
|
|
s.title,
|
|
s.is_default,
|
|
s.is_forced,
|
|
s.is_hearing_impaired,
|
|
s.channels,
|
|
s.channel_layout,
|
|
s.bit_rate,
|
|
s.sample_rate,
|
|
s.bit_depth,
|
|
);
|
|
}
|
|
|
|
// Create a stub review_plan so processInbox can find this item.
|
|
// Don't analyze yet — that happens during processing. Existing plans
|
|
// that are already sorted/approved/done stay untouched.
|
|
db
|
|
.prepare(`
|
|
INSERT INTO review_plans (item_id, status, is_noop, sorted)
|
|
VALUES (?, 'pending', 0, 0)
|
|
ON CONFLICT(item_id) DO UPDATE SET
|
|
-- Only reset to pending if the plan was in error state (retry).
|
|
-- Webhook source reopens done plans (file changed on disk).
|
|
-- Otherwise leave the plan alone — don't re-inbox processed items.
|
|
status = CASE
|
|
WHEN review_plans.status = 'error' THEN 'pending'
|
|
WHEN review_plans.status = 'done' AND ? = 'webhook' THEN 'pending'
|
|
ELSE review_plans.status
|
|
END,
|
|
sorted = CASE
|
|
WHEN review_plans.status = 'error' THEN 0
|
|
WHEN review_plans.status = 'done' AND ? = 'webhook' THEN 0
|
|
ELSE review_plans.sorted
|
|
END
|
|
`)
|
|
.run(itemId, source, source);
|
|
|
|
result.origLang = origLang;
|
|
result.origLangSource = origLangSource;
|
|
result.needsReview = needsReview;
|
|
result.isNoop = false;
|
|
})();
|
|
|
|
return result;
|
|
}
|