trigger sonarr/radarr rename after successful transcode
Build and Push Docker Image / build (push) Successful in 4m56s

After an FFmpeg job completes, send RescanMovie/RescanSeries followed by
RenameFiles to the appropriate *arr service so the filename reflects the
new codec/stream info. Updates media_items.file_path with the new name so
subsequent operations target the right file.

Path matching uses basename only since *arr services may see different
absolute paths than us due to Docker volume mappings. The directory part
of the path stays the same during a rename.

Non-blocking — rename failures only log a warning, never affect job status.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-22 07:29:17 +02:00
parent c045f6ad80
commit 7953d1b789
4 changed files with 180 additions and 3 deletions
+39
View File
@@ -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<void> {
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<void> {
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;
+71 -1
View File
@@ -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<string, unknown>): Promise<void> {
/**
* 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<RadarrMovie[]>(`${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<string, unknown>): Promise<number> {
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<string, unknown>, timeoutMs = 30_000): Promise<void> {
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 {
+69 -1
View File
@@ -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<string, unknown>): Promise<void> {
/**
* 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<SonarrSeries[]>(
`${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<string, unknown>): Promise<number> {
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<string, unknown>, timeoutMs = 30_000): Promise<void> {
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 {