diff --git a/package.json b/package.json index 73bd57a..ba1ade0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "netfelix-audio-fix", - "version": "2026.04.21.18", + "version": "2026.04.21.19", "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 a8e391a..3d75712 100644 --- a/server/api/execute.ts +++ b/server/api/execute.ts @@ -5,6 +5,8 @@ import { getConfig, getDb } from "../db/index"; import { log, error as logError, warn } from "../lib/log"; import { parseId } from "../lib/validate"; import { isExtractableSubtitle } from "../services/ffmpeg"; +import * as radarr from "../services/radarr"; +import * as sonarr from "../services/sonarr"; import { getScheduleConfig, isInProcessWindow, @@ -510,6 +512,10 @@ async function runJob(job: Job): Promise { log(`Job ${job.id} completed successfully`); emitJobUpdate(job.id, "done", fullOutput); + + // Trigger Radarr/Sonarr rescan + rename in the background. + // Non-blocking: rename failures must not affect job status. + triggerPostJobRename(job.item_id).catch((e) => warn(`Post-job rename for item ${job.item_id}: ${e}`)); } catch (err) { logError(`Job ${job.id} failed:`, err); const fullOutput = `${outputLines.join("\n")}\n${String(err)}`; @@ -574,4 +580,37 @@ export function parseFFmpegProgress(line: string): number | null { return h * 3600 + m * 60 + s; } +// ─── Post-job 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. + */ +async function triggerPostJobRename(itemId: number): Promise { + const db = getDb(); + const item = db.prepare("SELECT * FROM media_items WHERE id = ?").get(itemId) as MediaItem | undefined; + if (!item) return; + + 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); + } + + if (!result.ok) { + warn(`Rename for item ${itemId}: ${result.error}`); + return; + } + + 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}`); + } +} + export default app; diff --git a/server/services/radarr.ts b/server/services/radarr.ts index 6ca6b13..8ffa392 100644 --- a/server/services/radarr.ts +++ b/server/services/radarr.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import { error as logError, warn } from "../lib/log"; import { normalizeLanguage } from "./language-utils"; @@ -210,13 +211,82 @@ export async function triggerMovieRefetch( } } -async function postCommand(cfg: RadarrConfig, body: Record): Promise { +/** + * 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. + */ +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" }; + + 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" }; + + 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" }; + const movieId = matches[0]?.id; + if (movieId == null) return { ok: false, 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 }[]>( + `${cfg.url}/api/v3/rename?movieId=${movieId}`, + cfg, + `rename?movieId=${movieId}`, + ); + if (!renames || renames.length === 0) return { ok: true, newPath: currentPath }; + + // 3. Trigger rename for all files + const fileIds = renames.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 }; + } catch (e) { + return { ok: false, error: String(e) }; + } +} + +async function postCommand(cfg: RadarrConfig, body: Record): Promise { const res = await fetch(`${cfg.url}/api/v3/command`, { method: "POST", headers: { ...headers(cfg.apiKey), "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = (await res.json()) as { id: number }; + return data.id; +} + +async function postCommandAndWait(cfg: RadarrConfig, body: Record, timeoutMs = 30_000): Promise { + const cmdId = await postCommand(cfg, body); + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + await Bun.sleep(1000); + const cmd = await fetchJson<{ status: string }>(`${cfg.url}/api/v3/command/${cmdId}`, cfg, `command/${cmdId}`); + if (!cmd) break; + if (cmd.status === "completed") return; + if (cmd.status === "failed" || cmd.status === "aborted") throw new Error(`Command ${body.name} ${cmd.status}`); + } } function nameToIso(name: string): string | null { diff --git a/server/services/sonarr.ts b/server/services/sonarr.ts index 4c6b2f0..e32cc7b 100644 --- a/server/services/sonarr.ts +++ b/server/services/sonarr.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import { error as logError, warn } from "../lib/log"; import { normalizeLanguage } from "./language-utils"; @@ -201,13 +202,80 @@ export async function triggerEpisodeRefetch( } } -async function postCommand(cfg: SonarrConfig, body: Record): Promise { +/** + * 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. + */ +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" }; + + 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" }; + const seriesId = series[0]?.id; + if (seriesId == null) return { ok: false, 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 }[]>( + `${cfg.url}/api/v3/rename?seriesId=${seriesId}`, + cfg, + `rename?seriesId=${seriesId}`, + ); + if (!renames || renames.length === 0) return { ok: true, newPath: currentPath }; + + // 3. Trigger rename for all files in this series that need it + const fileIds = renames.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 }; + } catch (e) { + return { ok: false, error: String(e) }; + } +} + +async function postCommand(cfg: SonarrConfig, body: Record): Promise { const res = await fetch(`${cfg.url}/api/v3/command`, { method: "POST", headers: { ...headers(cfg.apiKey), "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = (await res.json()) as { id: number }; + return data.id; +} + +async function postCommandAndWait(cfg: SonarrConfig, body: Record, timeoutMs = 30_000): Promise { + const cmdId = await postCommand(cfg, body); + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + await Bun.sleep(1000); + const cmd = await fetchJson<{ status: string }>(`${cfg.url}/api/v3/command/${cmdId}`, cfg, `command/${cmdId}`); + if (!cmd) break; + if (cmd.status === "completed") return; + if (cmd.status === "failed" || cmd.status === "aborted") throw new Error(`Command ${body.name} ${cmd.status}`); + } } function nameToIso(name: string): string | null {