Store OpenSubtitles credentials in Admin settings and attach NL subs to Stremio streams.

No env vars needed; subtitle text is served from Master after download import.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-08-30 18:40:36 +02:00
parent 607386b94c
commit d89b207acf
13 changed files with 430 additions and 40 deletions

View file

@ -20,6 +20,16 @@ export default function SettingsPage() {
const [shelves, setShelves] = useState<ShelfRow[]>([]);
const [newShelfName, setNewShelfName] = useState("");
const [newShelfKind, setNewShelfKind] = useState<"MOVIE" | "SERIES">("MOVIE");
const [osApiKey, setOsApiKey] = useState("");
const [osUsername, setOsUsername] = useState("");
const [osPassword, setOsPassword] = useState("");
const [osEnabled, setOsEnabled] = useState(true);
const [osHasKey, setOsHasKey] = useState(false);
const [osHasPassword, setOsHasPassword] = useState(false);
const [osConfigured, setOsConfigured] = useState(false);
const [osBusy, setOsBusy] = useState(false);
const [osMsg, setOsMsg] = useState<string | null>(null);
const [osErr, setOsErr] = useState<string | null>(null);
const loadShelves = useCallback(() => {
fetch("/api/v1/admin/library/shelves", { credentials: "include" })
@ -27,9 +37,25 @@ export default function SettingsPage() {
.then((d) => setShelves(d.shelves ?? []));
}, []);
const loadOpenSubtitles = useCallback(() => {
fetch("/api/v1/admin/settings/opensubtitles", { credentials: "include" })
.then((r) => r.json())
.then((d) => {
const s = d.settings;
if (!s) return;
setOsConfigured(!!s.configured);
setOsEnabled(s.enabled !== false);
setOsUsername(s.username ?? "");
setOsHasKey(!!s.hasApiKey);
setOsHasPassword(!!s.hasPassword);
})
.catch(() => undefined);
}, []);
useEffect(() => {
loadShelves();
}, [loadShelves]);
loadOpenSubtitles();
}, [loadShelves, loadOpenSubtitles]);
async function createShelf() {
if (!newShelfName.trim()) return;
@ -62,6 +88,36 @@ export default function SettingsPage() {
loadShelves();
}
async function saveOpenSubtitles() {
setOsBusy(true);
setOsMsg(null);
setOsErr(null);
try {
const res = await fetch("/api/v1/admin/settings/opensubtitles", {
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
apiKey: osApiKey || undefined,
username: osUsername,
password: osPassword || undefined,
enabled: osEnabled,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setOsErr(data.error?.message ?? "Opslaan mislukt");
return;
}
setOsMsg("Opgeslagen — OpenSubtitles OK");
setOsApiKey("");
setOsPassword("");
loadOpenSubtitles();
} finally {
setOsBusy(false);
}
}
return (
<>
<Nav />
@ -206,6 +262,65 @@ export default function SettingsPage() {
</div>
</div>
</section>
<section className="stack-section">
<div className="section-head">
<h2 className="section-title">OpenSubtitles</h2>
<p className="muted">
Voor NL-ondertitels bij Downloads. Key aanmaken op opensubtitles.com → API consumers.
Gebruikersnaam/wachtwoord verhogen de downloadlimiet.
</p>
</div>
<div className="card">
<div className="form-grid">
<label>
API-key {osHasKey ? "(leeg = behouden)" : ""}
<input
type="password"
value={osApiKey}
onChange={(e) => setOsApiKey(e.target.value)}
placeholder={osHasKey ? "••••••••" : "API-key"}
autoComplete="off"
/>
</label>
<label>
Gebruikersnaam
<input
value={osUsername}
onChange={(e) => setOsUsername(e.target.value)}
autoComplete="username"
/>
</label>
<label>
Wachtwoord {osHasPassword ? "(leeg = behouden)" : ""}
<input
type="password"
value={osPassword}
onChange={(e) => setOsPassword(e.target.value)}
autoComplete="new-password"
/>
</label>
<label className="check-label" style={{ alignSelf: "end" }}>
<input
type="checkbox"
checked={osEnabled}
onChange={(e) => setOsEnabled(e.target.checked)}
/>
Ingeschakeld
</label>
</div>
<div className="inline-form" style={{ marginTop: "0.75rem" }}>
<button type="button" disabled={osBusy} onClick={() => void saveOpenSubtitles()}>
{osBusy ? "Bezig…" : "Opslaan + testen"}
</button>
<span className="muted">
Status: {osConfigured ? "geconfigureerd" : "niet geconfigureerd"}
</span>
{osMsg && <span className="ok-text">{osMsg}</span>}
{osErr && <span className="danger-text">{osErr}</span>}
</div>
</div>
</section>
</div>
</>
);

View file

@ -0,0 +1,35 @@
CREATE TABLE IF NOT EXISTS "integration_settings" (
"id" TEXT NOT NULL,
"api_key_enc" TEXT,
"username" TEXT,
"password_enc" TEXT,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"updated_at" TIMESTAMP(3) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "integration_settings_pkey" PRIMARY KEY ("id")
);
CREATE TABLE IF NOT EXISTS "library_subtitles" (
"id" TEXT NOT NULL,
"movie_id" TEXT,
"tmdb_id" INTEGER,
"imdb_id" TEXT,
"language" TEXT NOT NULL DEFAULT 'nl',
"format" TEXT NOT NULL DEFAULT 'srt',
"content" TEXT NOT NULL,
"file_name" TEXT,
"download_job_id" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "library_subtitles_pkey" PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "library_subtitles_movie_id_idx" ON "library_subtitles"("movie_id");
CREATE INDEX IF NOT EXISTS "library_subtitles_tmdb_id_idx" ON "library_subtitles"("tmdb_id");
CREATE INDEX IF NOT EXISTS "library_subtitles_imdb_id_idx" ON "library_subtitles"("imdb_id");
DO $$ BEGIN
ALTER TABLE "library_subtitles"
ADD CONSTRAINT "library_subtitles_movie_id_fkey"
FOREIGN KEY ("movie_id") REFERENCES "movies"("id") ON DELETE SET NULL ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN null; END $$;

View file

@ -234,6 +234,7 @@ model Movie {
mediaFiles MediaFile[]
matches LibraryMatch[]
subtitles LibrarySubtitle[]
@@map("movies")
}
@ -426,3 +427,38 @@ model DownloadJob {
@@index([nodeId, status])
@@map("download_jobs")
}
/** Singleton-style integration credentials (OpenSubtitles, etc.). */
model IntegrationSetting {
id String @id
apiKeyEnc String? @map("api_key_enc")
username String?
passwordEnc String? @map("password_enc")
enabled Boolean @default(true)
updatedAt DateTime @updatedAt @map("updated_at")
createdAt DateTime @default(now()) @map("created_at")
@@map("integration_settings")
}
/** Sidecar / OpenSubtitles text stored for Stremio stream.subtitles. */
model LibrarySubtitle {
id String @id @default(uuid())
movieId String? @map("movie_id")
tmdbId Int? @map("tmdb_id")
imdbId String? @map("imdb_id")
language String @default("nl")
format String @default("srt")
content String
fileName String? @map("file_name")
downloadJobId String? @map("download_job_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
movie Movie? @relation(fields: [movieId], references: [id], onDelete: SetNull)
@@index([movieId])
@@index([tmdbId])
@@index([imdbId])
@@map("library_subtitles")
}

View file

@ -11,6 +11,7 @@ import { registerStremioRoutes } from "./stremio/routes";
import { registerAdminRoutes } from "./admin/routes";
import { registerInstallRoutes } from "./install/routes";
import { registerDownloadRoutes } from "./downloads/routes";
import { registerSettingsRoutes } from "./settings/routes";
import { nodeConnectionManager } from "./websocket/manager";
import { MetadataService } from "./metadata/service";
import { toErrorResponse } from "./security/errors";
@ -73,6 +74,7 @@ async function main() {
await registerAdminRoutes(app, config);
await registerInstallRoutes(app, config);
registerDownloadRoutes(app, config);
registerSettingsRoutes(app, config);
app.get("/api/v1/node/connect", { websocket: true }, (socket) => {
void nodeConnectionManager.handleConnection(socket);
@ -245,6 +247,34 @@ async function main() {
await prisma.$executeRawUnsafe(
`ALTER TABLE "download_jobs" ADD COLUMN IF NOT EXISTS "subtitle_lang" TEXT`
);
await prisma.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS "integration_settings" (
"id" TEXT NOT NULL,
"api_key_enc" TEXT,
"username" TEXT,
"password_enc" TEXT,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "integration_settings_pkey" PRIMARY KEY ("id")
)
`);
await prisma.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS "library_subtitles" (
"id" TEXT NOT NULL,
"movie_id" TEXT,
"tmdb_id" INTEGER,
"imdb_id" TEXT,
"language" TEXT NOT NULL DEFAULT 'nl',
"format" TEXT NOT NULL DEFAULT 'srt',
"content" TEXT NOT NULL,
"file_name" TEXT,
"download_job_id" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "library_subtitles_pkey" PRIMARY KEY ("id")
)
`);
} catch (err) {
console.warn("Could not ensure download tables:", err);
}

View file

@ -16,9 +16,6 @@ const configSchema = z.object({
NODE_HEARTBEAT_INTERVAL_SECONDS: z.coerce.number().default(30),
NODE_OFFLINE_THRESHOLD_SECONDS: z.coerce.number().default(90),
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>;

View file

@ -7,7 +7,7 @@ import { DownloadService, startDownloadPoller } from "./service";
export function registerDownloadRoutes(app: FastifyInstance, config: Config): DownloadService {
const metadata = new MetadataService(config.TMDB_API_KEY);
const downloads = new DownloadService(config.SESSION_SECRET, metadata, config);
const downloads = new DownloadService(config.SESSION_SECRET, metadata);
startDownloadPoller(downloads);
app.get("/api/v1/admin/downloads/configs", { preHandler: requireAdmin }, async () => {

View file

@ -15,7 +15,7 @@ import {
extractReleaseTags,
type SubtitleHit,
} from "../opensubtitles/client";
import type { Config } from "../config";
import { createOpenSubtitlesClientFromDb } from "../settings/opensubtitles";
function normalizeBaseUrl(raw: string): string {
let u = raw.trim().replace(/\/+$/, "");
@ -39,24 +39,17 @@ export type DsConfigInput = {
};
export class DownloadService {
private readonly opensubtitles: OpenSubtitlesClient | null;
constructor(
private readonly sessionSecret: string,
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;
private readonly metadata: MetadataService
) {}
private async opensubtitles(): Promise<OpenSubtitlesClient | null> {
return createOpenSubtitlesClientFromDb(this.sessionSecret);
}
hasOpenSubtitles(): boolean {
return !!this.opensubtitles;
async hasOpenSubtitles(): Promise<boolean> {
return !!(await this.opensubtitles());
}
async searchSubtitles(opts: {
@ -65,7 +58,8 @@ export class DownloadService {
languages?: string;
releaseHint?: string;
}): Promise<{ imdbId: string | null; results: SubtitleHit[]; configured: boolean }> {
if (!this.opensubtitles) {
const client = await this.opensubtitles();
if (!client) {
return { imdbId: null, results: [], configured: false };
}
let imdbId = opts.imdbId?.trim() || null;
@ -76,12 +70,11 @@ export class DownloadService {
return { imdbId: null, results: [], configured: true };
}
const languages = opts.languages?.trim() || "nl";
const results = await this.opensubtitles.search({
const results = await client.search({
imdbId,
languages,
query: opts.releaseHint,
});
// Prefer NL + best overlap with release hint
const hint = (opts.releaseHint || "").toLowerCase();
const scored = results
.map((r) => {
@ -517,10 +510,15 @@ export class DownloadService {
let subtitle:
| { language: string; extension?: string; contentBase64: string }
| undefined;
if (job.subtitleFileId && this.opensubtitles) {
let subtitleText: string | null = null;
let subtitleFileName: string | null = null;
const osClient = await this.opensubtitles();
if (job.subtitleFileId && osClient) {
try {
const file = await this.opensubtitles.downloadFile(job.subtitleFileId);
const file = await osClient.downloadFile(job.subtitleFileId);
const ext = file.fileName.toLowerCase().endsWith(".ass") ? ".ass" : ".srt";
subtitleText = file.content.toString("utf8");
subtitleFileName = file.fileName;
subtitle = {
language: job.subtitleLang || "nl",
extension: ext,
@ -529,7 +527,6 @@ export class DownloadService {
} 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
}
}
@ -572,6 +569,30 @@ export class DownloadService {
error: null,
},
});
if (subtitleText) {
try {
let movieId: string | null = null;
if (job.tmdbId) {
const movie = await prisma.movie.findUnique({ where: { tmdbId: job.tmdbId } });
movieId = movie?.id ?? null;
}
await prisma.librarySubtitle.create({
data: {
movieId,
tmdbId: job.tmdbId,
imdbId: job.imdbId,
language: job.subtitleLang || "nl",
format: subtitle?.extension?.replace(/^\./, "") || "srt",
content: subtitleText,
fileName: subtitleFileName,
downloadJobId: job.id,
},
});
} catch (err) {
console.warn(`[downloads] save library subtitle job=${jobId}:`, err);
}
}
}
}

View file

@ -0,0 +1,92 @@
import { prisma } from "../database/client";
import { decryptSecret, encryptSecret } from "../security/crypto";
import { AppError } from "../security/errors";
import { OpenSubtitlesClient } from "../opensubtitles/client";
const OS_ID = "opensubtitles";
export type OpenSubtitlesSettingsPublic = {
configured: boolean;
enabled: boolean;
username: string;
hasApiKey: boolean;
hasPassword: boolean;
};
export async function getOpenSubtitlesSettingsPublic(): Promise<OpenSubtitlesSettingsPublic> {
const row = await prisma.integrationSetting.findUnique({ where: { id: OS_ID } });
return {
configured: !!(row?.apiKeyEnc && row.enabled),
enabled: row?.enabled ?? true,
username: row?.username ?? "",
hasApiKey: !!row?.apiKeyEnc,
hasPassword: !!row?.passwordEnc,
};
}
export async function upsertOpenSubtitlesSettings(
sessionSecret: string,
input: {
apiKey?: string;
username?: string;
password?: string;
enabled?: boolean;
}
): Promise<OpenSubtitlesSettingsPublic> {
const existing = await prisma.integrationSetting.findUnique({ where: { id: OS_ID } });
const apiKeyTrim = input.apiKey?.trim();
if (!apiKeyTrim && !existing?.apiKeyEnc) {
throw new AppError("INVALID_REQUEST", "API-key is verplicht", 400);
}
const apiKeyEnc = apiKeyTrim
? encryptSecret(apiKeyTrim, sessionSecret)
: existing!.apiKeyEnc;
const passwordEnc =
input.password !== undefined
? input.password.trim()
? encryptSecret(input.password.trim(), sessionSecret)
: null
: existing?.passwordEnc ?? null;
const username =
input.username !== undefined ? input.username.trim() || null : existing?.username ?? null;
await prisma.integrationSetting.upsert({
where: { id: OS_ID },
create: {
id: OS_ID,
apiKeyEnc,
username,
passwordEnc,
enabled: input.enabled ?? true,
},
update: {
apiKeyEnc,
username,
passwordEnc,
enabled: input.enabled ?? true,
},
});
// Validate by searching a known movie (optional light check: login if creds)
const client = await createOpenSubtitlesClientFromDb(sessionSecret);
if (!client) throw new AppError("OPENSUBTITLES", "Kon client niet maken", 400);
try {
await client.search({ imdbId: "tt1375666", languages: "nl" });
} catch (err) {
const message = err instanceof Error ? err.message : "OpenSubtitles test mislukt";
throw new AppError("OPENSUBTITLES", message, 400);
}
return getOpenSubtitlesSettingsPublic();
}
export async function createOpenSubtitlesClientFromDb(
sessionSecret: string
): Promise<OpenSubtitlesClient | null> {
const row = await prisma.integrationSetting.findUnique({ where: { id: OS_ID } });
if (!row?.enabled || !row.apiKeyEnc) return null;
const apiKey = decryptSecret(row.apiKeyEnc, sessionSecret);
const password = row.passwordEnc ? decryptSecret(row.passwordEnc, sessionSecret) : undefined;
return new OpenSubtitlesClient(apiKey, row.username ?? undefined, password);
}

View file

@ -0,0 +1,38 @@
import type { FastifyInstance } from "fastify";
import { requireAdmin } from "../auth/routes";
import type { Config } from "../config";
import { AppError } from "../security/errors";
import {
getOpenSubtitlesSettingsPublic,
upsertOpenSubtitlesSettings,
} from "./opensubtitles";
export function registerSettingsRoutes(app: FastifyInstance, config: Config) {
app.get("/api/v1/admin/settings/opensubtitles", { preHandler: requireAdmin }, async () => {
return { settings: await getOpenSubtitlesSettingsPublic() };
});
app.put("/api/v1/admin/settings/opensubtitles", { preHandler: requireAdmin }, async (request) => {
const body = request.body as {
apiKey?: string;
username?: string;
password?: string;
enabled?: boolean;
};
const settings = await upsertOpenSubtitlesSettings(config.SESSION_SECRET, body);
return { settings };
});
app.post(
"/api/v1/admin/settings/opensubtitles/test",
{ preHandler: requireAdmin },
async () => {
const pub = await getOpenSubtitlesSettingsPublic();
if (!pub.configured) {
throw new AppError("NOT_CONFIGURED", "OpenSubtitles nog niet geconfigureerd", 400);
}
// Re-save path already validates; expose status
return { ok: true, settings: pub };
}
);
}

View file

@ -282,10 +282,29 @@ export async function registerStremioRoutes(app: FastifyInstance, config: Config
return { streams };
}
const streams = await getStreams(type, id, auth, playback, metadata, request.log);
const streams = await getStreams(type, id, auth, playback, metadata, config.PUBLIC_URL, request.log);
return { streams };
});
app.get("/stremio/:token/subtitles/:subtitleId", async (request, reply) => {
const { token, subtitleId } = request.params as { token: string; subtitleId: string };
const auth = await resolveAddonToken(token);
if (!auth) {
return reply.status(401).send({ error: "Invalid addon token" });
}
const id = subtitleId.replace(/\.srt$/i, "").replace(/\.ass$/i, "");
const row = await prisma.librarySubtitle.findUnique({ where: { id } });
if (!row) {
return reply.status(404).send({ error: "Subtitle not found" });
}
const mime = row.format === "ass" ? "text/x-ssa" : "application/x-subrip";
reply
.header("Access-Control-Allow-Origin", "*")
.header("Content-Type", `${mime}; charset=utf-8`)
.header("Cache-Control", "public, max-age=86400");
return reply.send(row.content);
});
// Lazy playback: session is created only when Stremio actually opens this URL.
app.route({
method: ["GET", "HEAD"],
@ -800,8 +819,10 @@ async function getStreams(
auth: { userId: string; rawToken: string; tokenId: string },
playback: PlaybackService,
metadata: MetadataService,
publicUrl: string,
log?: { warn: (obj: unknown, msg?: string) => void; info?: (obj: unknown, msg?: string) => void }
) {
const base = publicUrl.replace(/\/+$/, "");
if (type === "movie") {
const localUuid = unwrapLocalId(id, "movie");
const isImdb = /^tt\d+$/i.test(id);
@ -854,6 +875,23 @@ async function getStreams(
// Never fall back to unavailable legacy paths (/media/movies) — those break playback.
const pool = movie.mediaFiles.filter((f) => f.available);
const librarySubs = await prisma.librarySubtitle.findMany({
where: {
OR: [
{ movieId: movie.id },
...(movie.tmdbId != null ? [{ tmdbId: movie.tmdbId }] : []),
...(movie.imdbId ? [{ imdbId: movie.imdbId }] : []),
],
},
orderBy: { createdAt: "desc" },
take: 8,
});
const stremioSubs = librarySubs.map((s) => ({
id: s.id,
url: `${base}/stremio/${auth.rawToken}/subtitles/${s.id}.${s.format || "srt"}`,
lang: s.language === "nl" || s.language.startsWith("nl") ? "nld" : s.language,
}));
const streams = [];
for (const file of pool) {
if (file.node.status !== "ONLINE" || file.node.revoked) continue;
@ -867,6 +905,7 @@ async function getStreams(
name: playback.formatStreamName(file),
title: playback.formatStreamTitle(file, file.node.name),
url: streamUrl,
...(stremioSubs.length ? { subtitles: stremioSubs } : {}),
behaviorHints: {
notWebReady: true,
},

View file

@ -52,9 +52,6 @@ services:
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-changeme123}
PUBLIC_URL: ${PUBLIC_URL:-https://master.vonas.nl}
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_ABSOLUTE_LIFETIME_SECONDS: ${PLAYBACK_ABSOLUTE_LIFETIME_SECONDS:-43200}
NODE_HEARTBEAT_INTERVAL_SECONDS: ${NODE_HEARTBEAT_INTERVAL_SECONDS:-30}

View file

@ -24,11 +24,6 @@ PUBLIC_URL=https://master.jouwdomein.nl
# Optioneel – betere metadata matching
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_ABSOLUTE_LIFETIME_SECONDS=43200
NODE_HEARTBEAT_INTERVAL_SECONDS=30

View file

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