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:
parent
607386b94c
commit
d89b207acf
13 changed files with 430 additions and 40 deletions
|
|
@ -20,6 +20,16 @@ export default function SettingsPage() {
|
||||||
const [shelves, setShelves] = useState<ShelfRow[]>([]);
|
const [shelves, setShelves] = useState<ShelfRow[]>([]);
|
||||||
const [newShelfName, setNewShelfName] = useState("");
|
const [newShelfName, setNewShelfName] = useState("");
|
||||||
const [newShelfKind, setNewShelfKind] = useState<"MOVIE" | "SERIES">("MOVIE");
|
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(() => {
|
const loadShelves = useCallback(() => {
|
||||||
fetch("/api/v1/admin/library/shelves", { credentials: "include" })
|
fetch("/api/v1/admin/library/shelves", { credentials: "include" })
|
||||||
|
|
@ -27,9 +37,25 @@ export default function SettingsPage() {
|
||||||
.then((d) => setShelves(d.shelves ?? []));
|
.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(() => {
|
useEffect(() => {
|
||||||
loadShelves();
|
loadShelves();
|
||||||
}, [loadShelves]);
|
loadOpenSubtitles();
|
||||||
|
}, [loadShelves, loadOpenSubtitles]);
|
||||||
|
|
||||||
async function createShelf() {
|
async function createShelf() {
|
||||||
if (!newShelfName.trim()) return;
|
if (!newShelfName.trim()) return;
|
||||||
|
|
@ -62,6 +88,36 @@ export default function SettingsPage() {
|
||||||
loadShelves();
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Nav />
|
<Nav />
|
||||||
|
|
@ -206,6 +262,65 @@ export default function SettingsPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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 $$;
|
||||||
|
|
@ -234,6 +234,7 @@ model Movie {
|
||||||
|
|
||||||
mediaFiles MediaFile[]
|
mediaFiles MediaFile[]
|
||||||
matches LibraryMatch[]
|
matches LibraryMatch[]
|
||||||
|
subtitles LibrarySubtitle[]
|
||||||
|
|
||||||
@@map("movies")
|
@@map("movies")
|
||||||
}
|
}
|
||||||
|
|
@ -426,3 +427,38 @@ model DownloadJob {
|
||||||
@@index([nodeId, status])
|
@@index([nodeId, status])
|
||||||
@@map("download_jobs")
|
@@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")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { registerStremioRoutes } from "./stremio/routes";
|
||||||
import { registerAdminRoutes } from "./admin/routes";
|
import { registerAdminRoutes } from "./admin/routes";
|
||||||
import { registerInstallRoutes } from "./install/routes";
|
import { registerInstallRoutes } from "./install/routes";
|
||||||
import { registerDownloadRoutes } from "./downloads/routes";
|
import { registerDownloadRoutes } from "./downloads/routes";
|
||||||
|
import { registerSettingsRoutes } from "./settings/routes";
|
||||||
import { nodeConnectionManager } from "./websocket/manager";
|
import { nodeConnectionManager } from "./websocket/manager";
|
||||||
import { MetadataService } from "./metadata/service";
|
import { MetadataService } from "./metadata/service";
|
||||||
import { toErrorResponse } from "./security/errors";
|
import { toErrorResponse } from "./security/errors";
|
||||||
|
|
@ -73,6 +74,7 @@ async function main() {
|
||||||
await registerAdminRoutes(app, config);
|
await registerAdminRoutes(app, config);
|
||||||
await registerInstallRoutes(app, config);
|
await registerInstallRoutes(app, config);
|
||||||
registerDownloadRoutes(app, config);
|
registerDownloadRoutes(app, config);
|
||||||
|
registerSettingsRoutes(app, config);
|
||||||
|
|
||||||
app.get("/api/v1/node/connect", { websocket: true }, (socket) => {
|
app.get("/api/v1/node/connect", { websocket: true }, (socket) => {
|
||||||
void nodeConnectionManager.handleConnection(socket);
|
void nodeConnectionManager.handleConnection(socket);
|
||||||
|
|
@ -245,6 +247,34 @@ async function main() {
|
||||||
await prisma.$executeRawUnsafe(
|
await prisma.$executeRawUnsafe(
|
||||||
`ALTER TABLE "download_jobs" ADD COLUMN IF NOT EXISTS "subtitle_lang" TEXT`
|
`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) {
|
} catch (err) {
|
||||||
console.warn("Could not ensure download tables:", err);
|
console.warn("Could not ensure download tables:", err);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,6 @@ 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, config);
|
const downloads = new DownloadService(config.SESSION_SECRET, metadata);
|
||||||
startDownloadPoller(downloads);
|
startDownloadPoller(downloads);
|
||||||
|
|
||||||
app.get("/api/v1/admin/downloads/configs", { preHandler: requireAdmin }, async () => {
|
app.get("/api/v1/admin/downloads/configs", { preHandler: requireAdmin }, async () => {
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import {
|
||||||
extractReleaseTags,
|
extractReleaseTags,
|
||||||
type SubtitleHit,
|
type SubtitleHit,
|
||||||
} from "../opensubtitles/client";
|
} from "../opensubtitles/client";
|
||||||
import type { Config } from "../config";
|
import { createOpenSubtitlesClientFromDb } from "../settings/opensubtitles";
|
||||||
|
|
||||||
function normalizeBaseUrl(raw: string): string {
|
function normalizeBaseUrl(raw: string): string {
|
||||||
let u = raw.trim().replace(/\/+$/, "");
|
let u = raw.trim().replace(/\/+$/, "");
|
||||||
|
|
@ -39,24 +39,17 @@ 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()
|
private async opensubtitles(): Promise<OpenSubtitlesClient | null> {
|
||||||
? new OpenSubtitlesClient(
|
return createOpenSubtitlesClientFromDb(this.sessionSecret);
|
||||||
config.OPENSUBTITLES_API_KEY.trim(),
|
|
||||||
config.OPENSUBTITLES_USERNAME,
|
|
||||||
config.OPENSUBTITLES_PASSWORD
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
hasOpenSubtitles(): boolean {
|
async hasOpenSubtitles(): Promise<boolean> {
|
||||||
return !!this.opensubtitles;
|
return !!(await this.opensubtitles());
|
||||||
}
|
}
|
||||||
|
|
||||||
async searchSubtitles(opts: {
|
async searchSubtitles(opts: {
|
||||||
|
|
@ -65,7 +58,8 @@ export class DownloadService {
|
||||||
languages?: string;
|
languages?: string;
|
||||||
releaseHint?: string;
|
releaseHint?: string;
|
||||||
}): Promise<{ imdbId: string | null; results: SubtitleHit[]; configured: boolean }> {
|
}): Promise<{ imdbId: string | null; results: SubtitleHit[]; configured: boolean }> {
|
||||||
if (!this.opensubtitles) {
|
const client = await this.opensubtitles();
|
||||||
|
if (!client) {
|
||||||
return { imdbId: null, results: [], configured: false };
|
return { imdbId: null, results: [], configured: false };
|
||||||
}
|
}
|
||||||
let imdbId = opts.imdbId?.trim() || null;
|
let imdbId = opts.imdbId?.trim() || null;
|
||||||
|
|
@ -76,12 +70,11 @@ export class DownloadService {
|
||||||
return { imdbId: null, results: [], configured: true };
|
return { imdbId: null, results: [], configured: true };
|
||||||
}
|
}
|
||||||
const languages = opts.languages?.trim() || "nl";
|
const languages = opts.languages?.trim() || "nl";
|
||||||
const results = await this.opensubtitles.search({
|
const results = await client.search({
|
||||||
imdbId,
|
imdbId,
|
||||||
languages,
|
languages,
|
||||||
query: opts.releaseHint,
|
query: opts.releaseHint,
|
||||||
});
|
});
|
||||||
// Prefer NL + best overlap with release hint
|
|
||||||
const hint = (opts.releaseHint || "").toLowerCase();
|
const hint = (opts.releaseHint || "").toLowerCase();
|
||||||
const scored = results
|
const scored = results
|
||||||
.map((r) => {
|
.map((r) => {
|
||||||
|
|
@ -517,10 +510,15 @@ export class DownloadService {
|
||||||
let subtitle:
|
let subtitle:
|
||||||
| { language: string; extension?: string; contentBase64: string }
|
| { language: string; extension?: string; contentBase64: string }
|
||||||
| undefined;
|
| 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 {
|
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";
|
const ext = file.fileName.toLowerCase().endsWith(".ass") ? ".ass" : ".srt";
|
||||||
|
subtitleText = file.content.toString("utf8");
|
||||||
|
subtitleFileName = file.fileName;
|
||||||
subtitle = {
|
subtitle = {
|
||||||
language: job.subtitleLang || "nl",
|
language: job.subtitleLang || "nl",
|
||||||
extension: ext,
|
extension: ext,
|
||||||
|
|
@ -529,7 +527,6 @@ export class DownloadService {
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : "Subtitle download mislukt";
|
const message = err instanceof Error ? err.message : "Subtitle download mislukt";
|
||||||
console.warn(`[downloads] subtitle job=${jobId}:`, message);
|
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,
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
92
apps/master-api/src/settings/opensubtitles.ts
Normal file
92
apps/master-api/src/settings/opensubtitles.ts
Normal 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);
|
||||||
|
}
|
||||||
38
apps/master-api/src/settings/routes.ts
Normal file
38
apps/master-api/src/settings/routes.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -282,10 +282,29 @@ export async function registerStremioRoutes(app: FastifyInstance, config: Config
|
||||||
return { streams };
|
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 };
|
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.
|
// Lazy playback: session is created only when Stremio actually opens this URL.
|
||||||
app.route({
|
app.route({
|
||||||
method: ["GET", "HEAD"],
|
method: ["GET", "HEAD"],
|
||||||
|
|
@ -800,8 +819,10 @@ async function getStreams(
|
||||||
auth: { userId: string; rawToken: string; tokenId: string },
|
auth: { userId: string; rawToken: string; tokenId: string },
|
||||||
playback: PlaybackService,
|
playback: PlaybackService,
|
||||||
metadata: MetadataService,
|
metadata: MetadataService,
|
||||||
|
publicUrl: string,
|
||||||
log?: { warn: (obj: unknown, msg?: string) => void; info?: (obj: unknown, msg?: string) => void }
|
log?: { warn: (obj: unknown, msg?: string) => void; info?: (obj: unknown, msg?: string) => void }
|
||||||
) {
|
) {
|
||||||
|
const base = publicUrl.replace(/\/+$/, "");
|
||||||
if (type === "movie") {
|
if (type === "movie") {
|
||||||
const localUuid = unwrapLocalId(id, "movie");
|
const localUuid = unwrapLocalId(id, "movie");
|
||||||
const isImdb = /^tt\d+$/i.test(id);
|
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.
|
// Never fall back to unavailable legacy paths (/media/movies) — those break playback.
|
||||||
const pool = movie.mediaFiles.filter((f) => f.available);
|
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 = [];
|
const streams = [];
|
||||||
for (const file of pool) {
|
for (const file of pool) {
|
||||||
if (file.node.status !== "ONLINE" || file.node.revoked) continue;
|
if (file.node.status !== "ONLINE" || file.node.revoked) continue;
|
||||||
|
|
@ -867,6 +905,7 @@ async function getStreams(
|
||||||
name: playback.formatStreamName(file),
|
name: playback.formatStreamName(file),
|
||||||
title: playback.formatStreamTitle(file, file.node.name),
|
title: playback.formatStreamTitle(file, file.node.name),
|
||||||
url: streamUrl,
|
url: streamUrl,
|
||||||
|
...(stremioSubs.length ? { subtitles: stremioSubs } : {}),
|
||||||
behaviorHints: {
|
behaviorHints: {
|
||||||
notWebReady: true,
|
notWebReady: true,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -52,9 +52,6 @@ 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,11 +24,6 @@ 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,11 +16,6 @@ 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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue