diff --git a/package.json b/package.json index b9b831a..f97a308 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "netfelix-audio-fix", - "version": "2026.04.21.20", + "version": "2026.04.22.1", "scripts": { "dev:server": "NODE_ENV=development bun --hot server/index.tsx", "dev:client": "vite", diff --git a/server/api/execute.ts b/server/api/execute.ts index bfaa535..a212041 100644 --- a/server/api/execute.ts +++ b/server/api/execute.ts @@ -593,37 +593,80 @@ export function parseFFmpegProgress(line: string): number | null { return h * 3600 + m * 60 + s; } -// ─── Post-job Radarr/Sonarr rename ─────────────────────────────────────────── +// ─── Radarr/Sonarr rename ───────────────────────────────────────────────────── /** - * After a successful FFmpeg job, tell Radarr/Sonarr to rescan the file and - * rename it according to its naming convention. If the path changes, update - * our media_items row so subsequent operations use the correct path. + * Trigger Radarr/Sonarr rename for the given items, deduplicated: one call + * per movie, one call per series (covers every episode of that series in + * one shot). After each call, updates file_path on every media_items row + * whose basename was renamed. + * + * Idempotent — *arr returns no work to do when filenames already match. + * Used after a job completes (fix-up post-transcode) and after processInbox + * classifies items as noop (catch lying filenames on already-clean files). */ -async function triggerPostJobRename(itemId: number): Promise { +async function triggerRenameFor(itemIds: number[]): Promise { + if (itemIds.length === 0) return; const db = getDb(); - const item = db.prepare("SELECT * FROM media_items WHERE id = ?").get(itemId) as MediaItem | undefined; - if (!item) return; + const placeholders = itemIds.map(() => "?").join(","); + const items = db + .prepare(`SELECT id, type, file_path, tmdb_id, imdb_id, tvdb_id FROM media_items WHERE id IN (${placeholders})`) + .all(...itemIds) as Pick[]; - let result: { ok: boolean; newPath?: string; error?: string }; - - if (item.type === "Movie") { - const cfg: radarr.RadarrConfig = { url: getConfig("radarr_url") ?? "", apiKey: getConfig("radarr_api_key") ?? "" }; - result = await radarr.triggerMovieRename(cfg, { tmdbId: item.tmdb_id, imdbId: item.imdb_id }, item.file_path); - } else { - const cfg: sonarr.SonarrConfig = { url: getConfig("sonarr_url") ?? "", apiKey: getConfig("sonarr_api_key") ?? "" }; - result = await sonarr.triggerSeriesRename(cfg, { tvdbId: item.tvdb_id }, item.file_path); + const movies = items.filter((i) => i.type === "Movie"); + const seriesByTvdb = new Map(); + for (const ep of items.filter((i) => i.type === "Episode")) { + if (!ep.tvdb_id) continue; + const list = seriesByTvdb.get(ep.tvdb_id) ?? []; + list.push(ep); + seriesByTvdb.set(ep.tvdb_id, list); } - if (!result.ok) { - warn(`Rename for item ${itemId}: ${result.error}`); - return; + const radarrCfg: radarr.RadarrConfig = { url: getConfig("radarr_url") ?? "", apiKey: getConfig("radarr_api_key") ?? "" }; + const sonarrCfg: sonarr.SonarrConfig = { url: getConfig("sonarr_url") ?? "", apiKey: getConfig("sonarr_api_key") ?? "" }; + + for (const movie of movies) { + const result = await radarr.triggerMovieRename(radarrCfg, { tmdbId: movie.tmdb_id, imdbId: movie.imdb_id }); + if (!result.ok) { + warn(`Rename for movie ${movie.id}: ${result.error}`); + continue; + } + applyRenamesToDb(db, result.renames); } - if (result.newPath && result.newPath !== item.file_path) { - db.prepare("UPDATE media_items SET file_path = ? WHERE id = ?").run(result.newPath, itemId); - log(`Item ${itemId} renamed: ${item.file_path} → ${result.newPath}`); + for (const tvdbId of seriesByTvdb.keys()) { + const result = await sonarr.triggerSeriesRename(sonarrCfg, { tvdbId }); + if (!result.ok) { + warn(`Rename for series tvdb=${tvdbId}: ${result.error}`); + continue; + } + applyRenamesToDb(db, result.renames); } } +function applyRenamesToDb(db: ReturnType, renames: Map): void { + if (renames.size === 0) return; + const sel = db.prepare("SELECT id, file_path FROM media_items WHERE file_path LIKE ?"); + const upd = db.prepare("UPDATE media_items SET file_path = ? WHERE id = ?"); + for (const [oldName, newName] of renames) { + if (oldName === newName) continue; + const rows = sel.all(`%/${oldName}`) as { id: number; file_path: string }[]; + for (const r of rows) { + const newPath = `${r.file_path.slice(0, r.file_path.length - oldName.length)}${newName}`; + upd.run(newPath, r.id); + log(`Item ${r.id} renamed: ${r.file_path} → ${newPath}`); + } + } +} + +/** Fire-and-forget rename trigger. Errors are logged, never thrown. */ +export function triggerRenameForItems(itemIds: number[]): void { + triggerRenameFor(itemIds).catch((e) => warn(`Rename batch failed: ${e}`)); +} + +/** Single-item helper used by the per-job post-success path. */ +function triggerPostJobRename(itemId: number): Promise { + return triggerRenameFor([itemId]); +} + export default app; diff --git a/server/api/review.ts b/server/api/review.ts index 1666240..8d625fe 100644 --- a/server/api/review.ts +++ b/server/api/review.ts @@ -29,6 +29,7 @@ import { emitInboxSortStart, emitPipelineChanged, maybeStartQueueProcessor, + triggerRenameForItems, } from "./execute"; const app = new Hono(); @@ -172,6 +173,7 @@ export async function processInbox( let movedToQueue = 0; let movedToReview = 0; let processed = 0; + const becameNoop: number[] = []; for (const { item_id } of unsortedIds) { if (hooks?.signal?.aborted) break; @@ -214,9 +216,13 @@ export async function processInbox( db.prepare("UPDATE review_plans SET sorted = 1 WHERE id = ?").run(plan.id); movedToReview += 1; } + } else if (plan?.is_noop) { + // Item went straight to "done" without a job. Its filename may still + // be lying about codecs/channels/quality — give *arr a chance to + // rename it. Idempotent if the name already matches the pattern. + becameNoop.push(item_id); } - // plans that vanished (!plan) or became noops fall through — the - // is_noop filter already excludes them from both Inbox and Review. + // plans that vanished (!plan) fall through. processed += 1; hooks?.onProgress?.(processed, total); @@ -227,6 +233,10 @@ export async function processInbox( } log(`processInbox complete: ${movedToQueue} → queue, ${movedToReview} → review, ${total - processed} skipped`); + if (becameNoop.length > 0) { + log(`processInbox: triggering rename for ${becameNoop.length} new noop items`); + triggerRenameForItems(becameNoop); + } return { moved_to_queue: movedToQueue, moved_to_review: movedToReview }; } @@ -839,7 +849,6 @@ app.get("/pipeline", (c) => { ) .get() as { n: number } ).n; - const reviewManualCount = reviewItemsTotal - reviewReadyCount; const autoProcessing = getConfig("auto_processing") === "1"; const autoProcessQueue = getConfig("auto_process_queue") === "1"; @@ -906,7 +915,6 @@ app.get("/pipeline", (c) => { inboxTotal, reviewItemsTotal, reviewReadyCount, - reviewManualCount, autoProcessing, autoProcessQueue, queued, diff --git a/server/services/radarr.ts b/server/services/radarr.ts index 8ffa392..d610c13 100644 --- a/server/services/radarr.ts +++ b/server/services/radarr.ts @@ -212,57 +212,48 @@ export async function triggerMovieRefetch( } /** - * After we remux/transcode a movie file, tell Radarr to rescan it and - * rename it according to its naming convention. Returns the new file path - * if it changed, or the old path if no rename was needed. + * Tell Radarr to rescan the movie's folder and rename any files whose names + * don't match its naming convention. Returns a basename → new-basename map + * so the caller can update its own path records (Radarr's absolute paths + * may differ from ours due to Docker volume mappings — basenames don't). */ export async function triggerMovieRename( cfg: RadarrConfig, ids: { tmdbId?: string | null; imdbId?: string | null }, - currentPath: string, -): Promise<{ ok: boolean; newPath?: string; error?: string }> { - if (!isUsable(cfg)) return { ok: false, error: "Radarr not configured" }; +): Promise<{ ok: boolean; renames: Map; error?: string }> { + const empty = new Map(); + if (!isUsable(cfg)) return { ok: false, renames: empty, error: "Radarr not configured" }; const query = ids.tmdbId ? `tmdbId=${encodeURIComponent(ids.tmdbId)}` : ids.imdbId ? `imdbId=${encodeURIComponent(ids.imdbId)}` : null; - if (!query) return { ok: false, error: "movie has no tmdb/imdb id" }; + if (!query) return { ok: false, renames: empty, error: "movie has no tmdb/imdb id" }; const matches = await fetchJson(`${cfg.url}/api/v3/movie?${query}`, cfg, `movie?${query}`); - if (!matches || matches.length === 0) return { ok: false, error: "movie not tracked by Radarr" }; + if (!matches || matches.length === 0) return { ok: false, renames: empty, error: "movie not tracked by Radarr" }; const movieId = matches[0]?.id; - if (movieId == null) return { ok: false, error: "Radarr movie missing id field" }; + if (movieId == null) return { ok: false, renames: empty, error: "Radarr movie missing id field" }; try { - // 1. Rescan so Radarr picks up the changed file await postCommandAndWait(cfg, { name: "RescanMovie", movieId }); - // 2. Check what files need renaming - const renames = await fetchJson<{ movieFileId: number; existingPath: string; newPath: string }[]>( + const previews = await fetchJson<{ movieFileId: number; existingPath: string; newPath: string }[]>( `${cfg.url}/api/v3/rename?movieId=${movieId}`, cfg, `rename?movieId=${movieId}`, ); - if (!renames || renames.length === 0) return { ok: true, newPath: currentPath }; + if (!previews || previews.length === 0) return { ok: true, renames: empty }; - // 3. Trigger rename for all files - const fileIds = renames.map((r) => r.movieFileId); + const fileIds = previews.map((r) => r.movieFileId); await postCommandAndWait(cfg, { name: "RenameFiles", movieId, files: fileIds }); - // 4. Find the new path for our file. Radarr's paths may differ from - // ours due to Docker volume mappings, so match by basename only and - // swap the filename portion in our path. - const ourBasename = path.basename(currentPath); - const match = renames.find((r) => path.basename(r.existingPath) === ourBasename); - if (match) { - const newBasename = path.basename(match.newPath); - return { ok: true, newPath: path.join(path.dirname(currentPath), newBasename) }; - } - return { ok: true, newPath: currentPath }; + const map = new Map(); + for (const r of previews) map.set(path.basename(r.existingPath), path.basename(r.newPath)); + return { ok: true, renames: map }; } catch (e) { - return { ok: false, error: String(e) }; + return { ok: false, renames: empty, error: String(e) }; } } diff --git a/server/services/sonarr.ts b/server/services/sonarr.ts index e32cc7b..024e40e 100644 --- a/server/services/sonarr.ts +++ b/server/services/sonarr.ts @@ -203,55 +203,46 @@ export async function triggerEpisodeRefetch( } /** - * After we remux/transcode an episode file, tell Sonarr to rescan the series - * and rename files according to its naming convention. Returns the new file - * path if it changed, or the old path if no rename was needed. + * Tell Sonarr to rescan the series and rename any files whose names don't + * match its naming convention. Returns a basename → new-basename map so the + * caller can update its own path records (Sonarr's absolute paths may differ + * from ours due to Docker volume mappings — basenames don't). */ export async function triggerSeriesRename( cfg: SonarrConfig, args: { tvdbId?: string | null }, - currentPath: string, -): Promise<{ ok: boolean; newPath?: string; error?: string }> { - if (!isUsable(cfg)) return { ok: false, error: "Sonarr not configured" }; - if (!args.tvdbId) return { ok: false, error: "episode has no tvdb id" }; +): Promise<{ ok: boolean; renames: Map; error?: string }> { + const empty = new Map(); + if (!isUsable(cfg)) return { ok: false, renames: empty, error: "Sonarr not configured" }; + if (!args.tvdbId) return { ok: false, renames: empty, error: "episode has no tvdb id" }; const series = await fetchJson( `${cfg.url}/api/v3/series?tvdbId=${encodeURIComponent(args.tvdbId)}`, cfg, `series?tvdbId=${args.tvdbId}`, ); - if (!series || series.length === 0) return { ok: false, error: "series not tracked by Sonarr" }; + if (!series || series.length === 0) return { ok: false, renames: empty, error: "series not tracked by Sonarr" }; const seriesId = series[0]?.id; - if (seriesId == null) return { ok: false, error: "Sonarr series missing id field" }; + if (seriesId == null) return { ok: false, renames: empty, error: "Sonarr series missing id field" }; try { - // 1. Rescan so Sonarr picks up the changed file await postCommandAndWait(cfg, { name: "RescanSeries", seriesId }); - // 2. Check what files need renaming - const renames = await fetchJson<{ episodeFileId: number; existingPath: string; newPath: string }[]>( + const previews = await fetchJson<{ episodeFileId: number; existingPath: string; newPath: string }[]>( `${cfg.url}/api/v3/rename?seriesId=${seriesId}`, cfg, `rename?seriesId=${seriesId}`, ); - if (!renames || renames.length === 0) return { ok: true, newPath: currentPath }; + if (!previews || previews.length === 0) return { ok: true, renames: empty }; - // 3. Trigger rename for all files in this series that need it - const fileIds = renames.map((r) => r.episodeFileId); + const fileIds = previews.map((r) => r.episodeFileId); await postCommandAndWait(cfg, { name: "RenameFiles", seriesId, files: fileIds }); - // 4. Find the new path for our file. Sonarr's paths may differ from - // ours due to Docker volume mappings, so match by basename only and - // swap the filename portion in our path. - const ourBasename = path.basename(currentPath); - const match = renames.find((r) => path.basename(r.existingPath) === ourBasename); - if (match) { - const newBasename = path.basename(match.newPath); - return { ok: true, newPath: path.join(path.dirname(currentPath), newBasename) }; - } - return { ok: true, newPath: currentPath }; + const map = new Map(); + for (const r of previews) map.set(path.basename(r.existingPath), path.basename(r.newPath)); + return { ok: true, renames: map }; } catch (e) { - return { ok: false, error: String(e) }; + return { ok: false, renames: empty, error: String(e) }; } } diff --git a/src/features/help/HelpPage.tsx b/src/features/help/HelpPage.tsx new file mode 100644 index 0000000..c8f9adf --- /dev/null +++ b/src/features/help/HelpPage.tsx @@ -0,0 +1,133 @@ +export function HelpPage() { + return ( +
+
+

How netfelix works

+

+ netfelix scans your media library, decides which audio tracks should stay, and rewrites the container without + re-encoding video. The goal is to run unattended — every step has a fully automated path. +

+
+ +
+

The pipeline

+

Every file moves through five columns left-to-right:

+
+
+
1. Inbox
+
+ Freshly scanned files. They carry only what the filesystem tells us — no Sonarr/Radarr lookup yet. +
+
+
+
2. Process Inbox (classification)
+
+ Looks up each item in Sonarr/Radarr to learn its original language, then decides whether the audio is + already clean (→ Done as a noop), can be auto-fixed (→ Queue), or needs you to choose (→ Review). +
+
+
+
3. Review
+
+ Items where the analyzer wants confirmation. With well-tagged files you should rarely see anything here. + If you do, it's usually because original language is unknown or the audio mix is unusual. Hit{" "} + Queue → on items you've + checked, or use the column header to queue everything the analyzer is confident about in one click. +
+
+
+
4. Queue → Processing
+
+ FFmpeg jobs run sequentially. Each job remuxes the container, keeping only the audio tracks the plan + marked as "keep", reordering them, and extracting subtitles to sidecar files. Video is always{" "} + -c copy — no re-encode. +
+
+
+
5. Done
+
+ Successful jobs land here. After each success (and after every noop) we ask Sonarr/Radarr to rescan and + rename the file according to their naming pattern, then update our DB with the new path. +
+
+
+
+ +
+

Hands-off mode

+

Two switches make the pipeline fully unattended:

+
    +
  • + Auto-process inbox — polls every few seconds for newly-scanned items and + classifies them. New items skip the Inbox column entirely. +
  • +
  • + Auto-process queue — drains the Queue as soon as items land in it. With + both switches on, a freshly-scanned file moves Inbox → Queue → Processing → Done without you touching + anything. +
  • +
+
+ +
+

Filename structure we require

+

+ netfelix only looks at folder layout, S/E patterns, and any provider IDs you've embedded. Codec, audio, and + quality brackets are not parsed — Sonarr/Radarr handle those, and we trigger their rename API after + every change. Use the{" "} + + TRaSH guides + {" "} + to configure *arr's naming. +

+

Movies

+
{`/movies/{Title (Year)}/{Title (Year)} [imdbid-ttNNNN] - [...].mkv`}
+

+ Year in (YYYY) on the folder is required. ID brackets ( + imdbid- / tmdbid-) are optional but help + with Radarr lookup. +

+

Episodes

+
{`/tv/{Series Title (Year)} [tvdbid-NNNN]/Season XX/{Series Title (Year)} - SXXEYY - {Episode Title} [...].mkv`}
+

+ The SXXEYY token (or SXXEYY-EZZ for + multi-episode files) is required. Episode title is parsed as everything between{" "} + SXXEYY - and the first [; if missing, + netfelix falls back to the SXXEYY identifier. +

+
+ +
+

Rules: what we keep

+
    +
  • + Video and data streams: always kept (copy mode, no re-encode). +
  • +
  • + Audio: keep the original language plus any languages you've configured + in Settings. Audio is reordered so original language comes first. +
  • +
  • + Subtitles: removed from the container, extracted to sidecar files next + to the video. Image-based subtitle codecs (PGS, VobSub) that can't be remuxed cleanly are dropped — managing + subtitle files lives in netfelix-subtitles-manager. +
  • +
+
+ +
+

When original language is unknown

+

+ If Sonarr/Radarr can't tell us the original language, the file is flagged for review and{" "} + no audio tracks are filtered until you set it manually on the detail page. We never strip tracks + blindly when we don't know what to keep. +

+
+
+ ); +} diff --git a/src/features/pipeline/PipelineCard.tsx b/src/features/pipeline/PipelineCard.tsx index 379bf63..927b76c 100644 --- a/src/features/pipeline/PipelineCard.tsx +++ b/src/features/pipeline/PipelineCard.tsx @@ -21,27 +21,6 @@ interface PipelineCardItem { audio_streams?: PipelineAudioStream[]; } -export function AutoClassBadge({ autoClass }: { autoClass: PipelineReviewItem["auto_class"] }) { - if (autoClass === "auto_heuristic") { - return ( - - ⚡ Auto-approve - - ); - } - if (autoClass === "manual") { - return ( - - ✋ Needs decision - - ); - } - return null; -} - interface PipelineCardProps { item: PipelineCardItem; /** Render title only — no badges, streams, or action buttons. Used in the inbox. */ @@ -115,7 +94,7 @@ export function PipelineCard({ const hasActionRow = !!(onSkip || onApprove || onUnapprove || onApproveUpToHere || onProcess || onRun || onBackToInbox); const hasReasons = !!item.reasons && item.reasons.length > 0; - const hasInfoRow = hasReasons || !!item.job_type || !!item.auto_class; + const hasInfoRow = hasReasons || !!item.job_type; return (
@@ -212,19 +191,16 @@ export function PipelineCard({
- {/* Info row: file info (transcode / copy) on the left, system status on the right. */} + {/* Info row: file info (transcode / copy) on the left. */} {!minimal && hasInfoRow && ( -
-
- {item.job_type && {item.job_type}} - {hasReasons && - item.reasons!.map((r) => ( - - {r} - - ))} -
- +
+ {item.job_type && {item.job_type}} + {hasReasons && + item.reasons!.map((r) => ( + + {r} + + ))}
)} diff --git a/src/features/pipeline/PipelinePage.tsx b/src/features/pipeline/PipelinePage.tsx index 139940d..5523cbf 100644 --- a/src/features/pipeline/PipelinePage.tsx +++ b/src/features/pipeline/PipelinePage.tsx @@ -166,7 +166,6 @@ export function PipelinePage() { initialResponse={reviewInitial} totalItems={data.reviewItemsTotal} readyCount={data.reviewReadyCount} - manualCount={data.reviewManualCount} onMutate={loadAll} sort={reviewSort} onChangeSort={(next) => { diff --git a/src/features/pipeline/ReviewColumn.tsx b/src/features/pipeline/ReviewColumn.tsx index be64717..be08f88 100644 --- a/src/features/pipeline/ReviewColumn.tsx +++ b/src/features/pipeline/ReviewColumn.tsx @@ -21,7 +21,6 @@ interface ReviewColumnProps { initialResponse: ReviewGroupsResponse; totalItems: number; readyCount: number; - manualCount: number; onMutate: () => void; sort: ReviewSort; onChangeSort: (next: ReviewSort) => void; @@ -31,7 +30,6 @@ export function ReviewColumn({ initialResponse, totalItems, readyCount, - manualCount, onMutate, sort, onChangeSort, @@ -132,13 +130,10 @@ export function ReviewColumn({ title: "Queue every auto-approvable item (no manual decision needed)", }; - const subtitle = totalItems === 0 ? undefined : `${readyCount} auto · ${manualCount} need decisions`; - return ( Paths Settings + Help
diff --git a/src/routes/help.tsx b/src/routes/help.tsx new file mode 100644 index 0000000..47715ea --- /dev/null +++ b/src/routes/help.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { HelpPage } from "~/features/help/HelpPage"; + +export const Route = createFileRoute("/help")({ + component: HelpPage, +}); diff --git a/src/shared/lib/types.ts b/src/shared/lib/types.ts index 4867567..00edef5 100644 --- a/src/shared/lib/types.ts +++ b/src/shared/lib/types.ts @@ -155,7 +155,6 @@ export interface PipelineData { inboxTotal: number; reviewItemsTotal: number; reviewReadyCount: number; - reviewManualCount: number; autoProcessing: boolean; autoProcessQueue: boolean; queued: PipelineJobItem[];