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 [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>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
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 };
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue