Keep release tags in imported filenames and add OpenSubtitles NL pick.

Downloads confirm flow can select a Dutch subtitle that is written beside the movie on import.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-08-30 18:35:05 +02:00
parent 9843d20614
commit 607386b94c
17 changed files with 615 additions and 33 deletions

View file

@ -42,6 +42,16 @@ interface MetaHit {
imdbId: string | null; imdbId: string | null;
} }
interface SubtitleHit {
fileId: number;
language: string;
release: string;
fileName: string;
downloadCount: number;
hearingImpaired: boolean;
uploader: string | null;
}
interface JobRow { interface JobRow {
id: string; id: string;
nodeId: string; nodeId: string;
@ -120,6 +130,11 @@ export default function DownloadsPage() {
const [selectedMeta, setSelectedMeta] = useState<MetaHit | null>(null); const [selectedMeta, setSelectedMeta] = useState<MetaHit | null>(null);
const [jobBusy, setJobBusy] = useState(false); const [jobBusy, setJobBusy] = useState(false);
const [jobErr, setJobErr] = useState<string | null>(null); const [jobErr, setJobErr] = useState<string | null>(null);
const [subs, setSubs] = useState<SubtitleHit[]>([]);
const [subsBusy, setSubsBusy] = useState(false);
const [subsConfigured, setSubsConfigured] = useState(true);
const [selectedSubId, setSelectedSubId] = useState<number | null>(null);
const [subsMsg, setSubsMsg] = useState<string | null>(null);
const [jobs, setJobs] = useState<JobRow[]>([]); const [jobs, setJobs] = useState<JobRow[]>([]);
@ -223,6 +238,9 @@ export default function DownloadsPage() {
setPick(hit); setPick(hit);
setSelectedMeta(null); setSelectedMeta(null);
setJobErr(null); setJobErr(null);
setSubs([]);
setSelectedSubId(null);
setSubsMsg(null);
setMetaQ(hit.suggestQuery || hit.title); setMetaQ(hit.suggestQuery || hit.title);
setMetaHits([]); setMetaHits([]);
await searchMeta(hit.suggestQuery || hit.title); await searchMeta(hit.suggestQuery || hit.title);
@ -244,6 +262,45 @@ export default function DownloadsPage() {
} }
} }
async function loadSubtitles(meta: MetaHit, releaseHint: string) {
setSubsBusy(true);
setSubsMsg(null);
setSubs([]);
setSelectedSubId(null);
try {
const params = new URLSearchParams({
languages: "nl",
releaseHint,
});
if (meta.imdbId) params.set("imdbId", meta.imdbId);
else params.set("tmdbId", String(meta.tmdbId));
const res = await fetch(`/api/v1/admin/downloads/subtitles?${params}`, {
credentials: "include",
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setSubsMsg(data.error?.message ?? "Ondertitels zoeken mislukt");
return;
}
setSubsConfigured(data.configured !== false);
if (data.configured === false) {
setSubsMsg("OpenSubtitles API-key niet geconfigureerd op Master");
return;
}
const results: SubtitleHit[] = data.results ?? [];
setSubs(results);
if (results[0]) setSelectedSubId(results[0].fileId);
else setSubsMsg("Geen NL-ondertitels gevonden");
} finally {
setSubsBusy(false);
}
}
async function selectMeta(m: MetaHit) {
setSelectedMeta(m);
if (pick) await loadSubtitles(m, pick.title);
}
async function confirmDownload() { async function confirmDownload() {
if (!pick || !selectedMeta || !pick.uri) { if (!pick || !selectedMeta || !pick.uri) {
setJobErr("Kies een match en zorg dat er een download-URI is"); setJobErr("Kies een match en zorg dat er een download-URI is");
@ -265,6 +322,8 @@ export default function DownloadsPage() {
movieTitle: selectedMeta.title, movieTitle: selectedMeta.title,
movieYear: selectedMeta.year, movieYear: selectedMeta.year,
posterUrl: selectedMeta.posterUrl, posterUrl: selectedMeta.posterUrl,
subtitleFileId: selectedSubId,
subtitleLang: selectedSubId ? "nl" : null,
}), }),
}); });
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
@ -276,6 +335,8 @@ export default function DownloadsPage() {
setHits([]); setHits([]);
setQuery(""); setQuery("");
setSearchErr(null); setSearchErr(null);
setSubs([]);
setSelectedSubId(null);
loadJobs(); loadJobs();
} finally { } finally {
setJobBusy(false); setJobBusy(false);
@ -560,7 +621,7 @@ export default function DownloadsPage() {
key={m.tmdbId} key={m.tmdbId}
type="button" type="button"
className={`match-item${selectedMeta?.tmdbId === m.tmdbId ? " selected" : ""}`} className={`match-item${selectedMeta?.tmdbId === m.tmdbId ? " selected" : ""}`}
onClick={() => setSelectedMeta(m)} onClick={() => void selectMeta(m)}
> >
{m.posterUrl && ( {m.posterUrl && (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
@ -579,6 +640,48 @@ export default function DownloadsPage() {
</button> </button>
))} ))}
</div> </div>
{selectedMeta && (
<div style={{ marginTop: "1rem" }}>
<h3 className="card-subtitle">Ondertitel (NL)</h3>
{subsBusy && <p className="muted">OpenSubtitles zoeken…</p>}
{subsMsg && <p className="muted">{subsMsg}</p>}
{!subsBusy && subsConfigured && (
<div className="match-list">
<button
type="button"
className={`match-item${selectedSubId == null ? " selected" : ""}`}
onClick={() => setSelectedSubId(null)}
>
<span>
<strong>Geen ondertitel</strong>
<small className="muted">Alleen film importeren</small>
</span>
</button>
{subs.map((s) => (
<button
key={s.fileId}
type="button"
className={`match-item${selectedSubId === s.fileId ? " selected" : ""}`}
onClick={() => setSelectedSubId(s.fileId)}
>
<span>
<strong>
{s.language.toUpperCase()} · {s.downloadCount} downloads
{s.hearingImpaired ? " · HI" : ""}
</strong>
<small className="muted">
{s.release || s.fileName}
{s.uploader ? ` · ${s.uploader}` : ""}
</small>
</span>
</button>
))}
</div>
)}
</div>
)}
{jobErr && <p className="danger-text">{jobErr}</p>} {jobErr && <p className="danger-text">{jobErr}</p>}
<div className="inline-form" style={{ marginTop: "1rem" }}> <div className="inline-form" style={{ marginTop: "1rem" }}>
<button type="button" disabled={jobBusy || !selectedMeta} onClick={() => void confirmDownload()}> <button type="button" disabled={jobBusy || !selectedMeta} onClick={() => void confirmDownload()}>

View file

@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "download_jobs" ADD COLUMN IF NOT EXISTS "release_tags" TEXT;
ALTER TABLE "download_jobs" ADD COLUMN IF NOT EXISTS "subtitle_file_id" INTEGER;
ALTER TABLE "download_jobs" ADD COLUMN IF NOT EXISTS "subtitle_lang" TEXT;

View file

@ -413,6 +413,9 @@ model DownloadJob {
destFolderName String? @map("dest_folder_name") destFolderName String? @map("dest_folder_name")
sourcePath String? @map("source_path") sourcePath String? @map("source_path")
importedPath String? @map("imported_path") importedPath String? @map("imported_path")
releaseTags String? @map("release_tags")
subtitleFileId Int? @map("subtitle_file_id")
subtitleLang String? @map("subtitle_lang")
error String? error String?
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")

View file

@ -236,6 +236,15 @@ async function main() {
await prisma.$executeRawUnsafe( await prisma.$executeRawUnsafe(
`CREATE INDEX IF NOT EXISTS "download_jobs_node_id_status_idx" ON "download_jobs"("node_id", "status")` `CREATE INDEX IF NOT EXISTS "download_jobs_node_id_status_idx" ON "download_jobs"("node_id", "status")`
); );
await prisma.$executeRawUnsafe(
`ALTER TABLE "download_jobs" ADD COLUMN IF NOT EXISTS "release_tags" TEXT`
);
await prisma.$executeRawUnsafe(
`ALTER TABLE "download_jobs" ADD COLUMN IF NOT EXISTS "subtitle_file_id" INTEGER`
);
await prisma.$executeRawUnsafe(
`ALTER TABLE "download_jobs" ADD COLUMN IF NOT EXISTS "subtitle_lang" TEXT`
);
} catch (err) { } catch (err) {
console.warn("Could not ensure download tables:", err); console.warn("Could not ensure download tables:", err);
} }

View file

@ -16,6 +16,9 @@ const configSchema = z.object({
NODE_HEARTBEAT_INTERVAL_SECONDS: z.coerce.number().default(30), NODE_HEARTBEAT_INTERVAL_SECONDS: z.coerce.number().default(30),
NODE_OFFLINE_THRESHOLD_SECONDS: z.coerce.number().default(90), NODE_OFFLINE_THRESHOLD_SECONDS: z.coerce.number().default(90),
HIDE_UNAVAILABLE_TITLES: z.coerce.boolean().default(true), HIDE_UNAVAILABLE_TITLES: z.coerce.boolean().default(true),
OPENSUBTITLES_API_KEY: z.string().optional(),
OPENSUBTITLES_USERNAME: z.string().optional(),
OPENSUBTITLES_PASSWORD: z.string().optional(),
}); });
export type Config = z.infer<typeof configSchema>; export type Config = z.infer<typeof configSchema>;

View file

@ -7,7 +7,7 @@ import { DownloadService, startDownloadPoller } from "./service";
export function registerDownloadRoutes(app: FastifyInstance, config: Config): DownloadService { export function registerDownloadRoutes(app: FastifyInstance, config: Config): DownloadService {
const metadata = new MetadataService(config.TMDB_API_KEY); const metadata = new MetadataService(config.TMDB_API_KEY);
const downloads = new DownloadService(config.SESSION_SECRET, metadata); const downloads = new DownloadService(config.SESSION_SECRET, metadata, config);
startDownloadPoller(downloads); startDownloadPoller(downloads);
app.get("/api/v1/admin/downloads/configs", { preHandler: requireAdmin }, async () => { app.get("/api/v1/admin/downloads/configs", { preHandler: requireAdmin }, async () => {
@ -57,6 +57,26 @@ export function registerDownloadRoutes(app: FastifyInstance, config: Config): Do
return { results }; return { results };
}); });
app.get("/api/v1/admin/downloads/subtitles", { preHandler: requireAdmin }, async (request) => {
const q = request.query as {
imdbId?: string;
tmdbId?: string;
languages?: string;
releaseHint?: string;
};
const tmdbId = q.tmdbId ? parseInt(q.tmdbId, 10) : undefined;
if (!q.imdbId?.trim() && (!tmdbId || !Number.isFinite(tmdbId))) {
throw new AppError("INVALID_REQUEST", "imdbId of tmdbId verplicht", 400);
}
const out = await downloads.searchSubtitles({
imdbId: q.imdbId,
tmdbId,
languages: q.languages || "nl",
releaseHint: q.releaseHint,
});
return out;
});
app.get("/api/v1/admin/downloads/jobs", { preHandler: requireAdmin }, async (request) => { app.get("/api/v1/admin/downloads/jobs", { preHandler: requireAdmin }, async (request) => {
const q = request.query as { limit?: string }; const q = request.query as { limit?: string };
const limit = q.limit ? parseInt(q.limit, 10) : 50; const limit = q.limit ? parseInt(q.limit, 10) : 50;
@ -74,6 +94,8 @@ export function registerDownloadRoutes(app: FastifyInstance, config: Config): Do
tmdbId: j.tmdbId, tmdbId: j.tmdbId,
imdbId: j.imdbId, imdbId: j.imdbId,
destFolderName: j.destFolderName, destFolderName: j.destFolderName,
releaseTags: j.releaseTags,
subtitleLang: j.subtitleLang,
importedPath: j.importedPath, importedPath: j.importedPath,
error: j.error, error: j.error,
createdAt: j.createdAt, createdAt: j.createdAt,
@ -92,6 +114,8 @@ export function registerDownloadRoutes(app: FastifyInstance, config: Config): Do
movieTitle?: string; movieTitle?: string;
movieYear?: number | null; movieYear?: number | null;
posterUrl?: string | null; posterUrl?: string | null;
subtitleFileId?: number | null;
subtitleLang?: string | null;
}; };
if (!body.nodeId || !body.uri || !body.movieTitle) { if (!body.nodeId || !body.uri || !body.movieTitle) {
throw new AppError("INVALID_REQUEST", "nodeId, uri en movieTitle verplicht", 400); throw new AppError("INVALID_REQUEST", "nodeId, uri en movieTitle verplicht", 400);
@ -105,6 +129,8 @@ export function registerDownloadRoutes(app: FastifyInstance, config: Config): Do
movieTitle: body.movieTitle, movieTitle: body.movieTitle,
movieYear: body.movieYear, movieYear: body.movieYear,
posterUrl: body.posterUrl, posterUrl: body.posterUrl,
subtitleFileId: body.subtitleFileId,
subtitleLang: body.subtitleLang,
}); });
return { job }; return { job };
}); });

View file

@ -10,6 +10,12 @@ import {
type DsSearchHit, type DsSearchHit,
type DsTask, type DsTask,
} from "../synology/download-station"; } from "../synology/download-station";
import {
OpenSubtitlesClient,
extractReleaseTags,
type SubtitleHit,
} from "../opensubtitles/client";
import type { Config } from "../config";
function normalizeBaseUrl(raw: string): string { function normalizeBaseUrl(raw: string): string {
let u = raw.trim().replace(/\/+$/, ""); let u = raw.trim().replace(/\/+$/, "");
@ -33,10 +39,66 @@ export type DsConfigInput = {
}; };
export class DownloadService { export class DownloadService {
private readonly opensubtitles: OpenSubtitlesClient | null;
constructor( constructor(
private readonly sessionSecret: string, private readonly sessionSecret: string,
private readonly metadata: MetadataService private readonly metadata: MetadataService,
) {} config?: Pick<Config, "OPENSUBTITLES_API_KEY" | "OPENSUBTITLES_USERNAME" | "OPENSUBTITLES_PASSWORD">
) {
this.opensubtitles = config?.OPENSUBTITLES_API_KEY?.trim()
? new OpenSubtitlesClient(
config.OPENSUBTITLES_API_KEY.trim(),
config.OPENSUBTITLES_USERNAME,
config.OPENSUBTITLES_PASSWORD
)
: null;
}
hasOpenSubtitles(): boolean {
return !!this.opensubtitles;
}
async searchSubtitles(opts: {
imdbId?: string;
tmdbId?: number;
languages?: string;
releaseHint?: string;
}): Promise<{ imdbId: string | null; results: SubtitleHit[]; configured: boolean }> {
if (!this.opensubtitles) {
return { imdbId: null, results: [], configured: false };
}
let imdbId = opts.imdbId?.trim() || null;
if (!imdbId && opts.tmdbId) {
imdbId = await this.metadata.resolveMovieImdbId(opts.tmdbId);
}
if (!imdbId) {
return { imdbId: null, results: [], configured: true };
}
const languages = opts.languages?.trim() || "nl";
const results = await this.opensubtitles.search({
imdbId,
languages,
query: opts.releaseHint,
});
// Prefer NL + best overlap with release hint
const hint = (opts.releaseHint || "").toLowerCase();
const scored = results
.map((r) => {
let score = r.downloadCount;
if (r.language === "nl" || r.language.startsWith("nl")) score += 1_000_000;
if (hint && r.release) {
const tokens = hint.split(/[^a-z0-9]+/).filter((t) => t.length > 2);
for (const t of tokens) {
if (r.release.toLowerCase().includes(t)) score += 50;
}
}
return { r, score };
})
.sort((a, b) => b.score - a.score)
.map((x) => x.r);
return { imdbId, results: scored, configured: true };
}
private async clientForNode(nodeId: string): Promise<{ private async clientForNode(nodeId: string): Promise<{
client: SynologyDownloadStation; client: SynologyDownloadStation;
@ -205,6 +267,8 @@ export class DownloadService {
movieTitle: string; movieTitle: string;
movieYear?: number | null; movieYear?: number | null;
posterUrl?: string | null; posterUrl?: string | null;
subtitleFileId?: number | null;
subtitleLang?: string | null;
}) { }) {
const uri = input.uri.trim(); const uri = input.uri.trim();
if (!uri) throw new AppError("INVALID_REQUEST", "Download-URI ontbreekt", 400); if (!uri) throw new AppError("INVALID_REQUEST", "Download-URI ontbreekt", 400);
@ -217,14 +281,12 @@ export class DownloadService {
const { client, config } = await this.clientForNode(input.nodeId); const { client, config } = await this.clientForNode(input.nodeId);
// Resolve full metadata when possible
let tmdbId = input.tmdbId; let tmdbId = input.tmdbId;
let imdbId = input.imdbId?.trim() || null; let imdbId = input.imdbId?.trim() || null;
let title = input.movieTitle.trim(); let title = input.movieTitle.trim();
let year = input.movieYear ?? null; let year = input.movieYear ?? null;
let posterUrl = input.posterUrl ?? null; let posterUrl = input.posterUrl ?? null;
// Prefer IMDb resolve when only tt… is given
if (!tmdbId && imdbId) { if (!tmdbId && imdbId) {
const hits = await this.metadata.searchCatalog("movie", imdbId); const hits = await this.metadata.searchCatalog("movie", imdbId);
const hit = hits[0]; const hit = hits[0];
@ -237,7 +299,10 @@ export class DownloadService {
} }
} }
// Ensure movie row exists so library matching is smoother after import if (tmdbId && !imdbId) {
imdbId = await this.metadata.resolveMovieImdbId(tmdbId);
}
if (tmdbId) { if (tmdbId) {
try { try {
await prisma.movie.upsert({ await prisma.movie.upsert({
@ -262,8 +327,15 @@ export class DownloadService {
} }
const destFolderName = folderNameForMovie(title, year); const destFolderName = folderNameForMovie(title, year);
const releaseTags = extractReleaseTags(input.sourceTitle, title, year) || null;
const subtitleFileId =
input.subtitleFileId != null && Number.isFinite(input.subtitleFileId)
? Math.floor(input.subtitleFileId)
: null;
const subtitleLang = subtitleFileId
? (input.subtitleLang?.trim().toLowerCase() || "nl")
: null;
// Snapshot task ids before create so we can discover the new id
let beforeIds = new Set<string>(); let beforeIds = new Set<string>();
try { try {
const before = await client.listTasks(); const before = await client.listTasks();
@ -303,6 +375,9 @@ export class DownloadService {
movieYear: year, movieYear: year,
posterUrl, posterUrl,
destFolderName, destFolderName,
releaseTags,
subtitleFileId,
subtitleLang,
}, },
}); });
@ -439,6 +514,30 @@ export class DownloadService {
data: { status: "IMPORTING", sourcePath, destFolderName: destFolder, error: null }, data: { status: "IMPORTING", sourcePath, destFolderName: destFolder, error: null },
}); });
let subtitle:
| { language: string; extension?: string; contentBase64: string }
| undefined;
if (job.subtitleFileId && this.opensubtitles) {
try {
const file = await this.opensubtitles.downloadFile(job.subtitleFileId);
const ext = file.fileName.toLowerCase().endsWith(".ass") ? ".ass" : ".srt";
subtitle = {
language: job.subtitleLang || "nl",
extension: ext,
contentBase64: file.content.toString("base64"),
};
} catch (err) {
const message = err instanceof Error ? err.message : "Subtitle download mislukt";
console.warn(`[downloads] subtitle job=${jobId}:`, message);
// Import film anyway; note error on job after success if needed
}
}
const releaseTags =
job.releaseTags ||
extractReleaseTags(job.sourceTitle, job.movieTitle, job.movieYear) ||
undefined;
const ack = await nodeConnectionManager.importMediaAcked( const ack = await nodeConnectionManager.importMediaAcked(
job.nodeId, job.nodeId,
{ {
@ -447,7 +546,9 @@ export class DownloadService {
destDir, destDir,
movieTitle: job.movieTitle, movieTitle: job.movieTitle,
movieYear: job.movieYear, movieYear: job.movieYear,
releaseTags,
qualityHint: "2160p", qualityHint: "2160p",
subtitle,
}, },
120_000 120_000
); );

View file

@ -1587,6 +1587,12 @@ export class MetadataService {
return meta; return meta;
} }
/** Resolve IMDb id for a TMDB movie (tt…). */
async resolveMovieImdbId(tmdbId: number): Promise<string | null> {
const id = await this.fetchTmdbMovieImdbId(tmdbId);
return id ?? null;
}
private async fetchTmdbMovieImdbId(tmdbId: number): Promise<string | undefined> { private async fetchTmdbMovieImdbId(tmdbId: number): Promise<string | undefined> {
if (!this.tmdbApiKey) return undefined; if (!this.tmdbApiKey) return undefined;
const params = new URLSearchParams({ api_key: this.tmdbApiKey }); const params = new URLSearchParams({ api_key: this.tmdbApiKey });

View file

@ -0,0 +1,202 @@
import { AppError } from "../security/errors";
export type SubtitleHit = {
fileId: number;
language: string;
release: string;
fileName: string;
downloadCount: number;
hearingImpaired: boolean;
fps: number | null;
uploader: string | null;
};
type OsLogin = { token: string; baseUrl: string; expiresAt: number };
/**
* OpenSubtitles.com REST API (v1).
* Requires OPENSUBTITLES_API_KEY; login optional but needed for higher download quotas.
*/
export class OpenSubtitlesClient {
private loginCache: OsLogin | null = null;
constructor(
private readonly apiKey: string,
private readonly username?: string,
private readonly password?: string
) {}
private headers(extra?: Record<string, string>): Record<string, string> {
return {
"Api-Key": this.apiKey,
"User-Agent": "MediaCluster v1.3",
Accept: "application/json",
...extra,
};
}
private root(): string {
const base = this.loginCache?.baseUrl?.replace(/\/+$/, "");
if (base && !base.startsWith("http")) return `https://${base}`;
if (base) return base;
return "https://api.opensubtitles.com";
}
async ensureLogin(): Promise<string | null> {
if (!this.username?.trim() || !this.password?.trim()) return null;
if (this.loginCache && Date.now() < this.loginCache.expiresAt) {
return this.loginCache.token;
}
const res = await fetch(`${this.root()}/api/v1/login`, {
method: "POST",
headers: this.headers({ "Content-Type": "application/json" }),
body: JSON.stringify({
username: this.username.trim(),
password: this.password.trim(),
}),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new AppError(
"OPENSUBTITLES",
`OpenSubtitles login mislukt (${res.status}): ${text.slice(0, 160)}`,
400
);
}
const data = (await res.json()) as { token?: string; base_url?: string };
if (!data.token) throw new AppError("OPENSUBTITLES", "OpenSubtitles: geen token", 400);
this.loginCache = {
token: data.token,
baseUrl: data.base_url || "api.opensubtitles.com",
expiresAt: Date.now() + 20 * 60 * 60 * 1000,
};
return data.token;
}
async search(opts: {
imdbId: string;
languages?: string;
query?: string;
}): Promise<SubtitleHit[]> {
const imdb = opts.imdbId.trim().replace(/^tt/i, "");
if (!/^\d+$/.test(imdb)) return [];
const languages = (opts.languages || "nl").trim() || "nl";
const params = new URLSearchParams({
imdb_id: imdb,
languages,
type: "movie",
order_by: "download_count",
order_direction: "desc",
});
if (opts.query?.trim()) params.set("query", opts.query.trim());
const token = await this.ensureLogin().catch(() => null);
const headers = this.headers(
token ? { Authorization: `Bearer ${token}` } : undefined
);
const res = await fetch(`${this.root()}/api/v1/subtitles?${params}`, { headers });
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new AppError(
"OPENSUBTITLES",
`OpenSubtitles zoeken mislukt (${res.status}): ${text.slice(0, 160)}`,
400
);
}
const body = (await res.json()) as {
data?: Array<{
attributes?: {
language?: string;
release?: string;
download_count?: number;
hearing_impaired?: boolean;
fps?: number;
files?: Array<{ file_id?: number; file_name?: string }>;
uploader?: { name?: string };
};
}>;
};
const out: SubtitleHit[] = [];
for (const row of body.data ?? []) {
const a = row.attributes;
const file = a?.files?.[0];
if (!file?.file_id) continue;
out.push({
fileId: file.file_id,
language: (a?.language || languages).toLowerCase(),
release: a?.release || file.file_name || "",
fileName: file.file_name || a?.release || `sub-${file.file_id}.srt`,
downloadCount: a?.download_count ?? 0,
hearingImpaired: !!a?.hearing_impaired,
fps: a?.fps ?? null,
uploader: a?.uploader?.name ?? null,
});
}
return out.slice(0, 25);
}
async downloadFile(fileId: number): Promise<{ content: Buffer; fileName: string }> {
const token = await this.ensureLogin();
const headers = this.headers({
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
});
const res = await fetch(`${this.root()}/api/v1/download`, {
method: "POST",
headers,
body: JSON.stringify({ file_id: fileId }),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new AppError(
"OPENSUBTITLES",
`OpenSubtitles download mislukt (${res.status}): ${text.slice(0, 200)}`,
400
);
}
const data = (await res.json()) as { link?: string; file_name?: string };
if (!data.link) {
throw new AppError("OPENSUBTITLES", "OpenSubtitles: geen download-link", 400);
}
const fileRes = await fetch(data.link);
if (!fileRes.ok) {
throw new AppError("OPENSUBTITLES", `Subtitle ophalen mislukt (${fileRes.status})`, 400);
}
const buf = Buffer.from(await fileRes.arrayBuffer());
return {
content: buf,
fileName: data.file_name || `subtitle-${fileId}.srt`,
};
}
}
/** Keep release tags for filename / OpenSubtitles matching. */
export function extractReleaseTags(
release: string,
title: string,
year?: number | null
): string {
let s = release.trim();
s = s.replace(/\.(mkv|mp4|m4v|avi|ts|m2ts|srt|ass|sub)$/i, "");
s = s.replace(/[._]+/g, " ");
for (const w of title.split(/\s+/)) {
const t = w.trim();
if (t.length < 2) continue;
const re = new RegExp(`\\b${escapeRegExp(t)}\\b`, "ig");
s = s.replace(re, " ");
}
if (year && year > 1900) {
s = s.replace(new RegExp(`\\b${year}\\b`, "g"), " ");
}
s = s.replace(/\s+/g, " ").trim();
return s;
}
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

View file

@ -4,7 +4,7 @@ WORKDIR /app
FROM golang:1.22-alpine AS node-build FROM golang:1.22-alpine AS node-build
WORKDIR /src WORKDIR /src
ARG MEDIA_NODE_VERSION=1.3.0 ARG MEDIA_NODE_VERSION=1.3.1
RUN apk add --no-cache git ca-certificates RUN apk add --no-cache git ca-certificates
COPY node/media-node/ ./ COPY node/media-node/ ./
RUN go mod tidy \ RUN go mod tidy \

View file

@ -52,6 +52,9 @@ services:
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-changeme123} ADMIN_PASSWORD: ${ADMIN_PASSWORD:-changeme123}
PUBLIC_URL: ${PUBLIC_URL:-https://master.vonas.nl} PUBLIC_URL: ${PUBLIC_URL:-https://master.vonas.nl}
TMDB_API_KEY: ${TMDB_API_KEY:-} TMDB_API_KEY: ${TMDB_API_KEY:-}
OPENSUBTITLES_API_KEY: ${OPENSUBTITLES_API_KEY:-}
OPENSUBTITLES_USERNAME: ${OPENSUBTITLES_USERNAME:-}
OPENSUBTITLES_PASSWORD: ${OPENSUBTITLES_PASSWORD:-}
PLAYBACK_IDLE_TIMEOUT_SECONDS: ${PLAYBACK_IDLE_TIMEOUT_SECONDS:-1200} PLAYBACK_IDLE_TIMEOUT_SECONDS: ${PLAYBACK_IDLE_TIMEOUT_SECONDS:-1200}
PLAYBACK_ABSOLUTE_LIFETIME_SECONDS: ${PLAYBACK_ABSOLUTE_LIFETIME_SECONDS:-43200} PLAYBACK_ABSOLUTE_LIFETIME_SECONDS: ${PLAYBACK_ABSOLUTE_LIFETIME_SECONDS:-43200}
NODE_HEARTBEAT_INTERVAL_SECONDS: ${NODE_HEARTBEAT_INTERVAL_SECONDS:-30} NODE_HEARTBEAT_INTERVAL_SECONDS: ${NODE_HEARTBEAT_INTERVAL_SECONDS:-30}

View file

@ -24,6 +24,11 @@ PUBLIC_URL=https://master.jouwdomein.nl
# Optioneel – betere metadata matching # Optioneel – betere metadata matching
TMDB_API_KEY= TMDB_API_KEY=
# OpenSubtitles.com (ondertitels bij Downloads; API-key + account voor downloads)
OPENSUBTITLES_API_KEY=
OPENSUBTITLES_USERNAME=
OPENSUBTITLES_PASSWORD=
PLAYBACK_IDLE_TIMEOUT_SECONDS=1200 PLAYBACK_IDLE_TIMEOUT_SECONDS=1200
PLAYBACK_ABSOLUTE_LIFETIME_SECONDS=43200 PLAYBACK_ABSOLUTE_LIFETIME_SECONDS=43200
NODE_HEARTBEAT_INTERVAL_SECONDS=30 NODE_HEARTBEAT_INTERVAL_SECONDS=30

View file

@ -16,6 +16,11 @@ PUBLIC_URL=https://master.media.example.com
# Optional TMDB API key for metadata matching # Optional TMDB API key for metadata matching
TMDB_API_KEY= TMDB_API_KEY=
# OpenSubtitles.com (optional; for Downloads NL subs)
OPENSUBTITLES_API_KEY=
OPENSUBTITLES_USERNAME=
OPENSUBTITLES_PASSWORD=
PLAYBACK_IDLE_TIMEOUT_SECONDS=1200 PLAYBACK_IDLE_TIMEOUT_SECONDS=1200
PLAYBACK_ABSOLUTE_LIFETIME_SECONDS=43200 PLAYBACK_ABSOLUTE_LIFETIME_SECONDS=43200
NODE_HEARTBEAT_INTERVAL_SECONDS=30 NODE_HEARTBEAT_INTERVAL_SECONDS=30

View file

@ -24,7 +24,7 @@ import (
"github.com/sthmedia/media-node/internal/streaming" "github.com/sthmedia/media-node/internal/streaming"
) )
var version = "1.3.0" var version = "1.3.1"
func main() { func main() {
if len(os.Args) < 2 { if len(os.Args) < 2 {

View file

@ -2,6 +2,7 @@ package control
import ( import (
"context" "context"
"encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log" "log"
@ -403,14 +404,46 @@ func (c *Client) handleImportMedia(payload json.RawMessage) {
DestDir string `json:"destDir"` DestDir string `json:"destDir"`
MovieTitle string `json:"movieTitle"` MovieTitle string `json:"movieTitle"`
MovieYear *int `json:"movieYear"` MovieYear *int `json:"movieYear"`
ReleaseTags string `json:"releaseTags"`
QualityHint string `json:"qualityHint"` QualityHint string `json:"qualityHint"`
Subtitle *struct {
Language string `json:"language"`
Extension string `json:"extension"`
ContentBase64 string `json:"contentBase64"`
} `json:"subtitle"`
} }
if err := json.Unmarshal(payload, &p); err != nil || p.JobID == "" { if err := json.Unmarshal(payload, &p); err != nil || p.JobID == "" {
log.Printf("IMPORT_MEDIA: invalid payload") log.Printf("IMPORT_MEDIA: invalid payload")
return return
} }
result, err := importmedia.ImportMovie(p.SourcePath, p.DestDir, p.MovieTitle, p.MovieYear, p.QualityHint) opts := importmedia.Options{
SourcePath: p.SourcePath,
DestDir: p.DestDir,
Title: p.MovieTitle,
Year: p.MovieYear,
ReleaseTags: p.ReleaseTags,
QualityHint: p.QualityHint,
}
if p.Subtitle != nil && p.Subtitle.ContentBase64 != "" {
raw, err := decodeBase64(p.Subtitle.ContentBase64)
if err != nil {
ack := controlMessage("IMPORT_MEDIA_ACK", map[string]interface{}{
"jobId": p.JobID,
"ok": false,
"error": "ondertitel base64 ongeldig",
})
_ = c.write(ack)
return
}
opts.Subtitle = &importmedia.SubtitleFile{
Language: p.Subtitle.Language,
Ext: p.Subtitle.Extension,
Content: raw,
}
}
result, err := importmedia.ImportMovie(opts)
ok := err == nil ok := err == nil
errMsg := "" errMsg := ""
imported := "" imported := ""
@ -419,7 +452,7 @@ func (c *Client) handleImportMedia(payload json.RawMessage) {
log.Printf("IMPORT_MEDIA failed job=%s: %v", p.JobID, err) log.Printf("IMPORT_MEDIA failed job=%s: %v", p.JobID, err)
} else { } else {
imported = result.ImportedPath imported = result.ImportedPath
log.Printf("IMPORT_MEDIA ok job=%s path=%s", p.JobID, imported) log.Printf("IMPORT_MEDIA ok job=%s path=%s sub=%s", p.JobID, imported, result.SubtitlePath)
c.scanner.IndexPath(imported) c.scanner.IndexPath(imported)
} }
@ -434,6 +467,10 @@ func (c *Client) handleImportMedia(payload json.RawMessage) {
} }
} }
func decodeBase64(s string) ([]byte, error) {
return base64.StdEncoding.DecodeString(s)
}
func (c *Client) applyMediaPaths(movies, series []string) { func (c *Client) applyMediaPaths(movies, series []string) {
// nil means "not provided" — keep current. Empty slice means clear (rare). // nil means "not provided" — keep current. Empty slice means clear (rare).
if movies == nil && series == nil { if movies == nil && series == nil {

View file

@ -16,15 +16,35 @@ var videoExt = map[string]bool{
var sampleRe = regexp.MustCompile(`(?i)(?:^|[.\-_])(sample|trailer|preview)(?:[.\-_]|$)`) var sampleRe = regexp.MustCompile(`(?i)(?:^|[.\-_])(sample|trailer|preview)(?:[.\-_]|$)`)
type Result struct { type SubtitleFile struct {
ImportedPath string Language string
Ext string
Content []byte
} }
// ImportMovie moves the largest video from sourcePath into destDir as Title (Year) - 2160p.ext type Result struct {
// and cleans leftover files under the download folder when source was a directory. ImportedPath string
func ImportMovie(sourcePath, destDir, title string, year *int, qualityHint string) (*Result, error) { SubtitlePath string
sourcePath = filepath.Clean(strings.TrimSpace(sourcePath)) }
destDir = filepath.Clean(strings.TrimSpace(destDir))
type Options struct {
SourcePath string
DestDir string
Title string
Year *int
ReleaseTags string
QualityHint string
Subtitle *SubtitleFile
}
// ImportMovie moves the largest video into destDir as:
//
// Title (Year) - <release tags>.ext
//
// and optionally writes a sidecar subtitle next to it.
func ImportMovie(opts Options) (*Result, error) {
sourcePath := filepath.Clean(strings.TrimSpace(opts.SourcePath))
destDir := filepath.Clean(strings.TrimSpace(opts.DestDir))
if sourcePath == "" || destDir == "" { if sourcePath == "" || destDir == "" {
return nil, fmt.Errorf("source/dest pad ontbreekt") return nil, fmt.Errorf("source/dest pad ontbreekt")
} }
@ -39,25 +59,74 @@ func ImportMovie(sourcePath, destDir, title string, year *int, qualityHint strin
} }
ext := strings.ToLower(filepath.Ext(video)) ext := strings.ToLower(filepath.Ext(video))
base := sanitizeName(title) base := sanitizeName(opts.Title)
if year != nil && *year > 1900 { if opts.Year != nil && *opts.Year > 1900 {
base = fmt.Sprintf("%s (%d)", base, *year) base = fmt.Sprintf("%s (%d)", base, *opts.Year)
} }
q := strings.TrimSpace(qualityHint)
if q == "" { tags := strings.TrimSpace(opts.ReleaseTags)
q = "2160p" if tags == "" {
tags = ExtractReleaseTags(filepath.Base(video), opts.Title, opts.Year)
} }
destName := fmt.Sprintf("%s - %s%s", base, q, ext) if tags == "" {
tags = strings.TrimSpace(opts.QualityHint)
}
if tags == "" {
tags = "2160p"
}
tags = sanitizeName(tags)
destName := fmt.Sprintf("%s - %s%s", base, tags, ext)
destPath := filepath.Join(destDir, destName) destPath := filepath.Join(destDir, destName)
if err := moveFile(video, destPath); err != nil { if err := moveFile(video, destPath); err != nil {
return nil, err return nil, err
} }
// Cleanup download leftovers (only if source was a dir under a parent, or sibling junk) result := &Result{ImportedPath: destPath}
cleanupSource(sourcePath, video)
return &Result{ImportedPath: destPath}, nil if opts.Subtitle != nil && len(opts.Subtitle.Content) > 0 {
lang := strings.ToLower(strings.TrimSpace(opts.Subtitle.Language))
if lang == "" {
lang = "nl"
}
subExt := strings.ToLower(strings.TrimSpace(opts.Subtitle.Ext))
if subExt == "" {
subExt = ".srt"
}
if !strings.HasPrefix(subExt, ".") {
subExt = "." + subExt
}
stem := strings.TrimSuffix(destName, ext)
subPath := filepath.Join(destDir, fmt.Sprintf("%s.%s%s", stem, lang, subExt))
if err := os.WriteFile(subPath, opts.Subtitle.Content, 0o644); err != nil {
return nil, fmt.Errorf("ondertitel schrijven: %w", err)
}
result.SubtitlePath = subPath
}
cleanupSource(sourcePath, video)
return result, nil
}
// ExtractReleaseTags keeps quality/group tokens from a release name.
func ExtractReleaseTags(release, title string, year *int) string {
s := release
s = regexp.MustCompile(`(?i)\.(mkv|mp4|m4v|avi|ts|m2ts|srt|ass|sub)$`).ReplaceAllString(s, "")
s = strings.ReplaceAll(s, ".", " ")
s = strings.ReplaceAll(s, "_", " ")
for _, w := range strings.Fields(title) {
if len(w) < 2 {
continue
}
re := regexp.MustCompile(`(?i)\b` + regexp.QuoteMeta(w) + `\b`)
s = re.ReplaceAllString(s, " ")
}
if year != nil && *year > 1900 {
re := regexp.MustCompile(`\b` + fmt.Sprintf("%d", *year) + `\b`)
s = re.ReplaceAllString(s, " ")
}
return strings.Join(strings.Fields(s), " ")
} }
func findLargestVideo(sourcePath string) (string, error) { func findLargestVideo(sourcePath string) (string, error) {
@ -111,7 +180,6 @@ func moveFile(src, dst string) error {
if err := os.Rename(src, dst); err == nil { if err := os.Rename(src, dst); err == nil {
return nil return nil
} }
// Cross-device: copy + remove
in, err := os.Open(src) in, err := os.Open(src)
if err != nil { if err != nil {
return err return err
@ -136,7 +204,6 @@ func moveFile(src, dst string) error {
func cleanupSource(sourcePath, movedVideo string) { func cleanupSource(sourcePath, movedVideo string) {
info, err := os.Stat(sourcePath) info, err := os.Stat(sourcePath)
if err != nil { if err != nil {
// source was a file that moved away
parent := filepath.Dir(sourcePath) parent := filepath.Dir(sourcePath)
_ = removeEmptyDirs(parent) _ = removeEmptyDirs(parent)
return return
@ -171,7 +238,7 @@ func removeEmptyDirs(root string) error {
return nil return nil
}) })
for i := len(dirs) - 1; i >= 0; i-- { for i := len(dirs) - 1; i >= 0; i-- {
_ = os.Remove(dirs[i]) // only succeeds if empty _ = os.Remove(dirs[i])
} }
return nil return nil
} }

View file

@ -121,8 +121,16 @@ export interface ImportMediaPayload {
destDir: string; destDir: string;
movieTitle: string; movieTitle: string;
movieYear?: number | null; movieYear?: number | null;
/** Prefer 2160p in renamed filename */ /** Release tags kept from torrent/file name (UHD BluRay Atmos …) */
releaseTags?: string;
/** Fallback if releaseTags empty */
qualityHint?: string; qualityHint?: string;
/** Optional sidecar subtitle written next to the video */
subtitle?: {
language: string;
extension?: string;
contentBase64: string;
};
} }
export interface ImportMediaAckPayload { export interface ImportMediaAckPayload {