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:
parent
9843d20614
commit
607386b94c
17 changed files with 615 additions and 33 deletions
|
|
@ -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()}>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
|
||||||
202
apps/master-api/src/opensubtitles/client.ts
Normal file
202
apps/master-api/src/opensubtitles/client.ts
Normal 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, "\\$&");
|
||||||
|
}
|
||||||
|
|
@ -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 \
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue