trigger sonarr/radarr rename after successful transcode
Build and Push Docker Image / build (push) Successful in 4m56s
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:
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user