Add Xtream IPTV per addon token with Live TV, EPG, and icons.

This commit is contained in:
Jos Vooges | STH 2026-08-25 16:32:45 +02:00
parent b78f7af24f
commit 9b7e26041e
10 changed files with 1552 additions and 46 deletions

View file

@ -8,8 +8,33 @@ interface AddonTokenRow {
label: string | null; label: string | null;
revoked: boolean; revoked: boolean;
createdAt: string; createdAt: string;
hasIptv?: boolean;
iptvOk?: boolean;
} }
interface IptvForm {
label: string;
baseUrl: string;
username: string;
password: string;
enableLive: boolean;
enableVod: boolean;
enableSeries: boolean;
lastOkAt?: string | null;
lastError?: string | null;
hasPassword?: boolean;
}
const emptyIptv = (): IptvForm => ({
label: "",
baseUrl: "",
username: "",
password: "",
enableLive: true,
enableVod: true,
enableSeries: true,
});
const MASTER_URL = "https://master.vonas.nl"; const MASTER_URL = "https://master.vonas.nl";
export default function SettingsPage() { export default function SettingsPage() {
@ -18,6 +43,11 @@ export default function SettingsPage() {
const [addonToken, setAddonToken] = useState(""); const [addonToken, setAddonToken] = useState("");
const [installUrl, setInstallUrl] = useState(""); const [installUrl, setInstallUrl] = useState("");
const [label, setLabel] = useState("Stremio"); const [label, setLabel] = useState("Stremio");
const [iptvTokenId, setIptvTokenId] = useState<string | null>(null);
const [iptv, setIptv] = useState<IptvForm>(emptyIptv());
const [iptvBusy, setIptvBusy] = useState(false);
const [iptvMsg, setIptvMsg] = useState<string | null>(null);
const [iptvErr, setIptvErr] = useState<string | null>(null);
const loadTokens = useCallback(() => { const loadTokens = useCallback(() => {
fetch("/api/v1/admin/addon-tokens", { credentials: "include" }) fetch("/api/v1/admin/addon-tokens", { credentials: "include" })
@ -69,9 +99,131 @@ export default function SettingsPage() {
method: "DELETE", method: "DELETE",
credentials: "include", credentials: "include",
}); });
if (iptvTokenId === id) {
setIptvTokenId(null);
setIptv(emptyIptv());
}
loadTokens(); loadTokens();
} }
async function openIptv(tokenId: string) {
setIptvTokenId(tokenId);
setIptvMsg(null);
setIptvErr(null);
setIptvBusy(true);
try {
const res = await fetch(`/api/v1/admin/addon-tokens/${tokenId}/iptv`, {
credentials: "include",
});
const data = await res.json();
if (!res.ok) {
setIptvErr(data.error?.message ?? "Laden mislukt");
setIptv(emptyIptv());
return;
}
if (data.iptv) {
setIptv({
label: data.iptv.label ?? "",
baseUrl: data.iptv.baseUrl ?? "",
username: data.iptv.username ?? "",
password: "",
enableLive: data.iptv.enableLive ?? true,
enableVod: data.iptv.enableVod ?? true,
enableSeries: data.iptv.enableSeries ?? true,
lastOkAt: data.iptv.lastOkAt,
lastError: data.iptv.lastError,
hasPassword: data.iptv.hasPassword,
});
} else {
setIptv(emptyIptv());
}
} finally {
setIptvBusy(false);
}
}
async function saveIptv() {
if (!iptvTokenId) return;
setIptvBusy(true);
setIptvMsg(null);
setIptvErr(null);
try {
const res = await fetch(`/api/v1/admin/addon-tokens/${iptvTokenId}/iptv`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
label: iptv.label || undefined,
baseUrl: iptv.baseUrl,
username: iptv.username,
password: iptv.password || undefined,
enableLive: iptv.enableLive,
enableVod: iptv.enableVod,
enableSeries: iptv.enableSeries,
}),
});
const data = await res.json();
if (!res.ok) {
setIptvErr(data.error?.message ?? "Opslaan mislukt");
return;
}
setIptvMsg(
`Verbonden · ${data.iptv?.liveCategories ?? 0} live-bouquets. Herlaad de Stremio-addon om Live TV te zien.`
);
setIptv((s) => ({ ...s, password: "", hasPassword: true, lastError: null }));
loadTokens();
await openIptv(iptvTokenId);
} finally {
setIptvBusy(false);
}
}
async function testIptv() {
if (!iptvTokenId) return;
setIptvBusy(true);
setIptvMsg(null);
setIptvErr(null);
try {
const res = await fetch(`/api/v1/admin/addon-tokens/${iptvTokenId}/iptv/test`, {
method: "POST",
credentials: "include",
});
const data = await res.json();
if (!res.ok) {
setIptvErr(data.error?.message ?? "Test mislukt");
return;
}
setIptvMsg(
`OK · status ${data.status}` +
(data.expDate ? ` · exp ${data.expDate}` : "") +
` · live ${data.liveCategories} · films ${data.vodCategories} · series ${data.seriesCategories}`
);
loadTokens();
} finally {
setIptvBusy(false);
}
}
async function deleteIptv() {
if (!iptvTokenId) return;
if (!confirm("IPTV-userline ontkoppelen van deze addon?")) return;
setIptvBusy(true);
setIptvErr(null);
try {
await fetch(`/api/v1/admin/addon-tokens/${iptvTokenId}/iptv`, {
method: "DELETE",
credentials: "include",
});
setIptv(emptyIptv());
setIptvMsg("IPTV ontkoppeld");
loadTokens();
} finally {
setIptvBusy(false);
}
}
const activeToken = tokens.find((t) => t.id === iptvTokenId);
return ( return (
<> <>
<Nav /> <Nav />
@ -84,7 +236,7 @@ export default function SettingsPage() {
</h2> </h2>
<p style={{ color: "#a1a1aa", marginBottom: "1rem", fontSize: "0.875rem" }}> <p style={{ color: "#a1a1aa", marginBottom: "1rem", fontSize: "0.875rem" }}>
Genereer een addon-token om de bibliotheek in Stremio te installeren. Bewaar de URL — Genereer een addon-token om de bibliotheek in Stremio te installeren. Bewaar de URL —
die wordt maar één keer getoond. die wordt maar één keer getoond. Koppel daarna optioneel een Xtream-userline voor Live TV + EPG.
</p> </p>
<div className="form-group"> <div className="form-group">
<label>Label</label> <label>Label</label>
@ -104,7 +256,7 @@ export default function SettingsPage() {
)} )}
</div> </div>
<div className="card"> <div className="card" style={{ marginBottom: "2rem" }}>
<h2 style={{ fontSize: "1rem", color: "#fafafa", textTransform: "none", marginBottom: "1rem" }}> <h2 style={{ fontSize: "1rem", color: "#fafafa", textTransform: "none", marginBottom: "1rem" }}>
Bestaande addons Bestaande addons
</h2> </h2>
@ -113,21 +265,38 @@ export default function SettingsPage() {
<tr> <tr>
<th>Label</th> <th>Label</th>
<th>Status</th> <th>Status</th>
<th>IPTV</th>
<th>Aangemaakt</th> <th>Aangemaakt</th>
<th>Acties</th> <th>Acties</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{tokens.map((t) => ( {tokens.map((t) => (
<tr key={t.id}> <tr key={t.id} style={iptvTokenId === t.id ? { background: "#14161f" } : undefined}>
<td>{t.label || "—"}</td> <td>{t.label || "—"}</td>
<td> <td>
<span className={`status ${t.revoked ? "revoked" : "online"}`}> <span className={`status ${t.revoked ? "revoked" : "online"}`}>
{t.revoked ? "Inactief" : "Actief"} {t.revoked ? "Inactief" : "Actief"}
</span> </span>
</td> </td>
<td>
{t.hasIptv ? (
<span className={`status ${t.iptvOk ? "online" : "revoked"}`}>
{t.iptvOk ? "Live TV" : "Fout"}
</span>
) : (
<span className="status offline">Geen</span>
)}
</td>
<td>{new Date(t.createdAt).toLocaleString()}</td> <td>{new Date(t.createdAt).toLocaleString()}</td>
<td style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}> <td style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
<button
onClick={() => openIptv(t.id)}
style={{ background: "#0e7490" }}
title="Xtream / XUI userline"
>
IPTV
</button>
{t.revoked ? ( {t.revoked ? (
<button onClick={() => activateToken(t.id)} style={{ background: "#166534" }}> <button onClick={() => activateToken(t.id)} style={{ background: "#166534" }}>
Activeren Activeren
@ -145,7 +314,7 @@ export default function SettingsPage() {
))} ))}
{tokens.length === 0 && ( {tokens.length === 0 && (
<tr> <tr>
<td colSpan={4} style={{ textAlign: "center", color: "#71717a" }}> <td colSpan={5} style={{ textAlign: "center", color: "#71717a" }}>
Nog geen addons aangemaakt Nog geen addons aangemaakt
</td> </td>
</tr> </tr>
@ -157,6 +326,182 @@ export default function SettingsPage() {
De installatie-URL zelf wordt om veiligheidsredenen niet opnieuw getoond. De installatie-URL zelf wordt om veiligheidsredenen niet opnieuw getoond.
</p> </p>
</div> </div>
{iptvTokenId && (
<div
className="card"
style={{
marginBottom: "2rem",
borderColor: "#155e75",
background:
"linear-gradient(145deg, #12151c 0%, #152028 55%, #1a1d27 100%)",
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
gap: "1rem",
marginBottom: "1.25rem",
flexWrap: "wrap",
}}
>
<div>
<h2
style={{
fontSize: "1.125rem",
color: "#fafafa",
textTransform: "none",
marginBottom: "0.35rem",
letterSpacing: 0,
}}
>
IPTV · Xtream / XUI
</h2>
<p style={{ color: "#94a3b8", fontSize: "0.875rem", maxWidth: "40rem" }}>
Userline voor addon{" "}
<strong style={{ color: "#e2e8f0" }}>{activeToken?.label || iptvTokenId.slice(0, 8)}</strong>
. Live-zenders met icoon + EPG, plus optioneel IPTV-films en -series. Alleen deze
Stremio-installatie ziet deze line.
</p>
</div>
<button
type="button"
onClick={() => {
setIptvTokenId(null);
setIptv(emptyIptv());
setIptvMsg(null);
setIptvErr(null);
}}
style={{ background: "#374151" }}
>
Sluiten
</button>
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
gap: "1rem",
}}
>
<div className="form-group" style={{ marginBottom: 0 }}>
<label>Label (optioneel)</label>
<input
value={iptv.label}
onChange={(e) => setIptv({ ...iptv, label: e.target.value })}
placeholder="bijv. Thuis XUI"
/>
</div>
<div className="form-group" style={{ marginBottom: 0, gridColumn: "1 / -1" }}>
<label>Server-URL</label>
<input
value={iptv.baseUrl}
onChange={(e) => setIptv({ ...iptv, baseUrl: e.target.value })}
placeholder="http://host:port of https://iptv.example.com"
autoComplete="off"
/>
</div>
<div className="form-group" style={{ marginBottom: 0 }}>
<label>Gebruikersnaam</label>
<input
value={iptv.username}
onChange={(e) => setIptv({ ...iptv, username: e.target.value })}
autoComplete="off"
/>
</div>
<div className="form-group" style={{ marginBottom: 0 }}>
<label>Wachtwoord {iptv.hasPassword ? "(leeg = behouden)" : ""}</label>
<input
type="password"
value={iptv.password}
onChange={(e) => setIptv({ ...iptv, password: e.target.value })}
placeholder={iptv.hasPassword ? "••••••••" : ""}
autoComplete="new-password"
/>
</div>
</div>
<div
style={{
display: "flex",
gap: "1.25rem",
flexWrap: "wrap",
margin: "1.25rem 0",
padding: "0.85rem 1rem",
background: "#0f1117",
borderRadius: 10,
border: "1px solid #2a2d3a",
}}
>
{(
[
["enableLive", "Live TV + EPG"],
["enableVod", "IPTV Films"],
["enableSeries", "IPTV Series"],
] as const
).map(([key, text]) => (
<label
key={key}
style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
fontSize: "0.875rem",
color: "#e4e4e7",
cursor: "pointer",
}}
>
<input
type="checkbox"
checked={iptv[key]}
onChange={(e) => setIptv({ ...iptv, [key]: e.target.checked })}
style={{ width: "auto", margin: 0 }}
/>
{text}
</label>
))}
</div>
{(iptv.lastOkAt || iptv.lastError) && (
<p style={{ fontSize: "0.8rem", color: "#71717a", marginBottom: "1rem" }}>
{iptv.lastOkAt && <>Laatst OK: {new Date(iptv.lastOkAt).toLocaleString()} </>}
{iptv.lastError && (
<span style={{ color: "#f87171" }}>· Fout: {iptv.lastError}</span>
)}
</p>
)}
{iptvMsg && (
<p style={{ color: "#6ee7b7", fontSize: "0.875rem", marginBottom: "0.75rem" }}>{iptvMsg}</p>
)}
{iptvErr && (
<p className="error" style={{ marginBottom: "0.75rem" }}>
{iptvErr}
</p>
)}
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
<button onClick={saveIptv} disabled={iptvBusy}>
{iptvBusy ? "Bezig…" : "Opslaan & verbinden"}
</button>
<button
onClick={testIptv}
disabled={iptvBusy || !iptv.hasPassword}
style={{ background: "#166534" }}
>
Testen
</button>
{iptv.hasPassword && (
<button className="danger" onClick={deleteIptv} disabled={iptvBusy}>
Ontkoppelen
</button>
)}
</div>
</div>
)}
</div> </div>
</> </>
); );

View file

@ -0,0 +1,24 @@
-- CreateTable
CREATE TABLE "iptv_lines" (
"id" TEXT NOT NULL,
"addon_token_id" TEXT NOT NULL,
"label" TEXT,
"base_url" TEXT NOT NULL,
"username" TEXT NOT NULL,
"password_enc" TEXT NOT NULL,
"enable_live" BOOLEAN NOT NULL DEFAULT true,
"enable_vod" BOOLEAN NOT NULL DEFAULT true,
"enable_series" BOOLEAN NOT NULL DEFAULT true,
"last_ok_at" TIMESTAMP(3),
"last_error" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "iptv_lines_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "iptv_lines_addon_token_id_key" ON "iptv_lines"("addon_token_id");
-- AddForeignKey
ALTER TABLE "iptv_lines" ADD CONSTRAINT "iptv_lines_addon_token_id_fkey" FOREIGN KEY ("addon_token_id") REFERENCES "addon_tokens"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -65,10 +65,33 @@ model AddonToken {
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
iptvLine IptvLine?
@@map("addon_tokens") @@map("addon_tokens")
} }
/** Xtream/XUI credentials bound to one Stremio addon install (token). */
model IptvLine {
id String @id @default(uuid())
addonTokenId String @unique @map("addon_token_id")
label String?
baseUrl String @map("base_url")
username String
/** AES-GCM ciphertext (base64) */
passwordEnc String @map("password_enc")
enableLive Boolean @default(true) @map("enable_live")
enableVod Boolean @default(true) @map("enable_vod")
enableSeries Boolean @default(true) @map("enable_series")
lastOkAt DateTime? @map("last_ok_at")
lastError String? @map("last_error")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
addonToken AddonToken @relation(fields: [addonTokenId], references: [id], onDelete: Cascade)
@@map("iptv_lines")
}
model Location { model Location {
id String @id @default(uuid()) id String @id @default(uuid())
name String @unique name String @unique

View file

@ -331,6 +331,7 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
const tokens = await prisma.addonToken.findMany({ const tokens = await prisma.addonToken.findMany({
where: { userId: user.id }, where: { userId: user.id },
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
include: { iptvLine: { select: { id: true, enableLive: true, lastOkAt: true, lastError: true } } },
}); });
return { return {
tokens: tokens.map((t) => ({ tokens: tokens.map((t) => ({
@ -338,6 +339,8 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
label: t.label, label: t.label,
revoked: t.revoked, revoked: t.revoked,
createdAt: t.createdAt, createdAt: t.createdAt,
hasIptv: !!t.iptvLine,
iptvOk: t.iptvLine ? !t.iptvLine.lastError && !!t.iptvLine.lastOkAt : false,
})), })),
}; };
}); });
@ -460,6 +463,176 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
return result; return result;
}); });
app.get("/api/v1/admin/addon-tokens/:id/iptv", { preHandler: requireAdmin }, async (request) => {
const { id } = request.params as { id: string };
const user = (request as import("../auth/routes").AuthenticatedRequest).user;
const token = await prisma.addonToken.findFirst({
where: { id, userId: user.id },
include: { iptvLine: true },
});
if (!token) throw new AppError("NOT_FOUND", "Addon token not found", 404);
const line = token.iptvLine;
return {
iptv: line
? {
id: line.id,
label: line.label,
baseUrl: line.baseUrl,
username: line.username,
hasPassword: true,
enableLive: line.enableLive,
enableVod: line.enableVod,
enableSeries: line.enableSeries,
lastOkAt: line.lastOkAt,
lastError: line.lastError,
}
: null,
};
});
app.put("/api/v1/admin/addon-tokens/:id/iptv", { preHandler: requireAdmin }, async (request) => {
const { id } = request.params as { id: string };
const user = (request as import("../auth/routes").AuthenticatedRequest).user;
const body = request.body as {
label?: string;
baseUrl?: string;
username?: string;
password?: string;
enableLive?: boolean;
enableVod?: boolean;
enableSeries?: boolean;
};
const token = await prisma.addonToken.findFirst({
where: { id, userId: user.id },
include: { iptvLine: true },
});
if (!token) throw new AppError("NOT_FOUND", "Addon token not found", 404);
if (!body.baseUrl?.trim() || !body.username?.trim()) {
throw new AppError("INVALID_REQUEST", "baseUrl and username required", 400);
}
if (!body.password?.trim() && !token.iptvLine) {
throw new AppError("INVALID_REQUEST", "password required for new IPTV line", 400);
}
const { encryptSecret } = await import("../security/crypto");
const { XtreamClient, parseXtreamBaseUrl } = await import("../iptv/xtream");
const baseUrl = parseXtreamBaseUrl(body.baseUrl);
const passwordEnc = body.password?.trim()
? encryptSecret(body.password.trim(), config.SESSION_SECRET)
: token.iptvLine!.passwordEnc;
// Validate against Xtream before saving
try {
const client = XtreamClient.fromEncrypted({
baseUrl,
username: body.username.trim(),
passwordEnc,
sessionSecret: config.SESSION_SECRET,
});
await client.authenticate();
const liveCats = body.enableLive === false ? [] : await client.getLiveCategories().catch(() => []);
const line = await prisma.iptvLine.upsert({
where: { addonTokenId: id },
create: {
addonTokenId: id,
label: body.label?.trim() || null,
baseUrl,
username: body.username.trim(),
passwordEnc,
enableLive: body.enableLive ?? true,
enableVod: body.enableVod ?? true,
enableSeries: body.enableSeries ?? true,
lastOkAt: new Date(),
lastError: null,
},
update: {
label: body.label?.trim() || null,
baseUrl,
username: body.username.trim(),
passwordEnc,
enableLive: body.enableLive ?? true,
enableVod: body.enableVod ?? true,
enableSeries: body.enableSeries ?? true,
lastOkAt: new Date(),
lastError: null,
},
});
return {
ok: true,
iptv: {
id: line.id,
liveCategories: liveCats.length,
lastOkAt: line.lastOkAt,
},
};
} catch (err) {
const message = err instanceof Error ? err.message : "Xtream connect failed";
if (token.iptvLine) {
await prisma.iptvLine.update({
where: { id: token.iptvLine.id },
data: { lastError: message },
});
}
throw new AppError("IPTV_ERROR", message, 400);
}
});
app.delete("/api/v1/admin/addon-tokens/:id/iptv", { preHandler: requireAdmin }, async (request) => {
const { id } = request.params as { id: string };
const user = (request as import("../auth/routes").AuthenticatedRequest).user;
const token = await prisma.addonToken.findFirst({ where: { id, userId: user.id } });
if (!token) throw new AppError("NOT_FOUND", "Addon token not found", 404);
await prisma.iptvLine.deleteMany({ where: { addonTokenId: id } });
return { ok: true };
});
app.post("/api/v1/admin/addon-tokens/:id/iptv/test", { preHandler: requireAdmin }, async (request) => {
const { id } = request.params as { id: string };
const user = (request as import("../auth/routes").AuthenticatedRequest).user;
const token = await prisma.addonToken.findFirst({
where: { id, userId: user.id },
include: { iptvLine: true },
});
if (!token?.iptvLine) throw new AppError("NOT_FOUND", "Geen IPTV-line op deze addon", 404);
const { XtreamClient } = await import("../iptv/xtream");
try {
const client = XtreamClient.fromEncrypted({
baseUrl: token.iptvLine.baseUrl,
username: token.iptvLine.username,
passwordEnc: token.iptvLine.passwordEnc,
sessionSecret: config.SESSION_SECRET,
});
const auth = await client.authenticate();
const [live, vod, series] = await Promise.all([
client.getLiveCategories().catch(() => []),
client.getVodCategories().catch(() => []),
client.getSeriesCategories().catch(() => []),
]);
await prisma.iptvLine.update({
where: { id: token.iptvLine.id },
data: { lastOkAt: new Date(), lastError: null },
});
return {
ok: true,
status: auth.status,
expDate: auth.expDate,
liveCategories: live.length,
vodCategories: vod.length,
seriesCategories: series.length,
};
} catch (err) {
const message = err instanceof Error ? err.message : "Test mislukt";
await prisma.iptvLine.update({
where: { id: token.iptvLine.id },
data: { lastError: message },
});
throw new AppError("IPTV_ERROR", message, 400);
}
});
app.post("/api/v1/admin/movies", { preHandler: requireAdmin }, async (request) => { app.post("/api/v1/admin/movies", { preHandler: requireAdmin }, async (request) => {
const body = request.body as { const body = request.body as {
title: string; title: string;

View file

@ -91,6 +91,33 @@ async function main() {
console.warn("Could not ensure still_url/logo_url columns:", err); console.warn("Could not ensure still_url/logo_url columns:", err);
} }
// IPTV userlines (Xtream) bound to addon tokens.
try {
await prisma.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS "iptv_lines" (
"id" TEXT NOT NULL,
"addon_token_id" TEXT NOT NULL,
"label" TEXT,
"base_url" TEXT NOT NULL,
"username" TEXT NOT NULL,
"password_enc" TEXT NOT NULL,
"enable_live" BOOLEAN NOT NULL DEFAULT true,
"enable_vod" BOOLEAN NOT NULL DEFAULT true,
"enable_series" BOOLEAN NOT NULL DEFAULT true,
"last_ok_at" TIMESTAMP(3),
"last_error" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "iptv_lines_pkey" PRIMARY KEY ("id")
)
`);
await prisma.$executeRawUnsafe(
`CREATE UNIQUE INDEX IF NOT EXISTS "iptv_lines_addon_token_id_key" ON "iptv_lines"("addon_token_id")`
);
} catch (err) {
console.warn("Could not ensure iptv_lines table:", err);
}
// Repair drifted episode.series_id so series.episodes and seasons.episodes stay aligned. // Repair drifted episode.series_id so series.episodes and seasons.episodes stay aligned.
try { try {
await prisma.$executeRawUnsafe(` await prisma.$executeRawUnsafe(`

View file

@ -121,9 +121,20 @@ export async function ensureAdminUser(config: Config): Promise<void> {
} }
export async function validateAddonToken(token: string): Promise<string | null> { export async function validateAddonToken(token: string): Promise<string | null> {
const record = await resolveAddonToken(token);
return record?.userId ?? null;
}
export type ResolvedAddonToken = {
userId: string;
tokenId: string;
rawToken: string;
};
export async function resolveAddonToken(token: string): Promise<ResolvedAddonToken | null> {
const record = await prisma.addonToken.findUnique({ const record = await prisma.addonToken.findUnique({
where: { tokenHash: hashToken(token) }, where: { tokenHash: hashToken(token) },
}); });
if (!record || record.revoked) return null; if (!record || record.revoked) return null;
return record.userId; return { userId: record.userId, tokenId: record.id, rawToken: token };
} }

View file

@ -0,0 +1,492 @@
import { prisma } from "../database/client";
import type { Config } from "../config";
import {
XtreamClient,
decodeEpgText,
epgToIso,
parseXtreamBaseUrl,
type XtreamLiveStream,
type XtreamSeries,
type XtreamVodStream,
} from "./xtream";
const IPTV_PREFIX = "mciptv";
const PAGE = 100;
export function liveChannelId(streamId: number): string {
return `${IPTV_PREFIX}-live-${streamId}`;
}
export function vodId(streamId: number): string {
return `${IPTV_PREFIX}-vod-${streamId}`;
}
export function seriesId(seriesId: number): string {
return `${IPTV_PREFIX}-series-${seriesId}`;
}
export function parseIptvId(id: string): {
kind: "live" | "vod" | "series" | "series-ep";
streamId: number;
season?: number;
episode?: number;
episodeStreamId?: string;
} | null {
const live = id.match(new RegExp(`^${IPTV_PREFIX}-live-(\\d+)(?::epg:(\\d+))?$`));
if (live) return { kind: "live", streamId: parseInt(live[1], 10) };
const vod = id.match(new RegExp(`^${IPTV_PREFIX}-vod-(\\d+)$`));
if (vod) return { kind: "vod", streamId: parseInt(vod[1], 10) };
const seriesEp = id.match(
new RegExp(`^${IPTV_PREFIX}-series-(\\d+):(\\d+):(\\d+)(?::(.+))?$`)
);
if (seriesEp) {
return {
kind: "series-ep",
streamId: parseInt(seriesEp[1], 10),
season: parseInt(seriesEp[2], 10),
episode: parseInt(seriesEp[3], 10),
episodeStreamId: seriesEp[4],
};
}
const series = id.match(new RegExp(`^${IPTV_PREFIX}-series-(\\d+)$`));
if (series) return { kind: "series", streamId: parseInt(series[1], 10) };
return null;
}
export async function loadXtreamForToken(
tokenId: string,
config: Config
): Promise<{ client: XtreamClient; line: { enableLive: boolean; enableVod: boolean; enableSeries: boolean } } | null> {
const line = await prisma.iptvLine.findUnique({ where: { addonTokenId: tokenId } });
if (!line) return null;
try {
const client = XtreamClient.fromEncrypted({
baseUrl: line.baseUrl,
username: line.username,
passwordEnc: line.passwordEnc,
sessionSecret: config.SESSION_SECRET,
});
return {
client,
line: {
enableLive: line.enableLive,
enableVod: line.enableVod,
enableSeries: line.enableSeries,
},
};
} catch {
return null;
}
}
export async function buildIptvCatalogs(tokenId: string, config: Config) {
const xt = await loadXtreamForToken(tokenId, config);
if (!xt) return { catalogs: [] as unknown[], types: [] as string[], genreLive: [] as string[] };
const genreExtra = (options: string[]) => [
{ name: "genre", isRequired: false, options: options.length ? options : undefined },
{ name: "search", isRequired: false },
{ name: "skip", isRequired: false },
];
const catalogs: Array<Record<string, unknown>> = [];
const types = new Set<string>(["movie", "series"]);
let liveNames: string[] = [];
if (xt.line.enableLive) {
types.add("tv");
try {
const cats = await xt.client.getLiveCategories();
liveNames = cats.map((c) => c.category_name).filter(Boolean);
} catch {
liveNames = [];
}
catalogs.push({
type: "tv",
id: "iptv-live",
name: "Live TV",
extra: genreExtra(liveNames),
});
}
if (xt.line.enableVod) {
let vodNames: string[] = [];
try {
vodNames = (await xt.client.getVodCategories()).map((c) => c.category_name).filter(Boolean);
} catch {
vodNames = [];
}
catalogs.push({
type: "movie",
id: "iptv-movies",
name: "IPTV Films",
extra: genreExtra(vodNames),
});
}
if (xt.line.enableSeries) {
let seriesNames: string[] = [];
try {
seriesNames = (await xt.client.getSeriesCategories())
.map((c) => c.category_name)
.filter(Boolean);
} catch {
seriesNames = [];
}
catalogs.push({
type: "series",
id: "iptv-series",
name: "IPTV Series",
extra: genreExtra(seriesNames),
});
}
return { catalogs, types: [...types], genreLive: liveNames };
}
function matchCategoryId(
cats: Array<{ category_id: string; category_name: string }>,
genre?: string
): string | undefined {
if (!genre) return undefined;
const hit = cats.find((c) => c.category_name.toLowerCase() === genre.toLowerCase());
return hit?.category_id;
}
function filterSearch<T extends { name: string }>(rows: T[], search?: string): T[] {
if (!search) return rows;
const q = search.toLowerCase();
return rows.filter((r) => r.name.toLowerCase().includes(q));
}
export async function getIptvLiveCatalog(
tokenId: string,
config: Config,
opts: { genre?: string; search?: string; skip?: number }
) {
const xt = await loadXtreamForToken(tokenId, config);
if (!xt?.line.enableLive) return [];
const cats = await xt.client.getLiveCategories().catch(() => []);
const catId = matchCategoryId(cats, opts.genre);
let streams = await xt.client.getLiveStreams(catId);
streams = filterSearch(streams, opts.search);
streams = [...streams].sort((a, b) => (a.num ?? a.stream_id) - (b.num ?? b.stream_id));
const skip = opts.skip ?? 0;
const page = streams.slice(skip, skip + PAGE);
const catName = (s: XtreamLiveStream) =>
cats.find((c) => String(c.category_id) === String(s.category_id))?.category_name;
return page.map((s) => ({
id: liveChannelId(s.stream_id),
type: "tv" as const,
name: s.name,
poster: s.stream_icon || undefined,
posterShape: "square" as const,
logo: s.stream_icon || undefined,
genres: catName(s) ? [catName(s)!] : undefined,
}));
}
export async function getIptvVodCatalog(
tokenId: string,
config: Config,
opts: { genre?: string; search?: string; skip?: number }
) {
const xt = await loadXtreamForToken(tokenId, config);
if (!xt?.line.enableVod) return [];
const cats = await xt.client.getVodCategories().catch(() => []);
const catId = matchCategoryId(cats, opts.genre);
let streams = await xt.client.getVodStreams(catId);
streams = filterSearch(streams, opts.search);
const skip = opts.skip ?? 0;
const page = streams.slice(skip, skip + PAGE);
return page.map((s: XtreamVodStream) => ({
id: vodId(s.stream_id),
type: "movie" as const,
name: s.name,
poster: s.stream_icon || undefined,
background: s.backdrop_path?.[0] || undefined,
description: s.plot || undefined,
releaseInfo: s.releaseDate?.slice?.(0, 4) || undefined,
imdbRating: s.rating || undefined,
genres: s.genre ? s.genre.split(",").map((g) => g.trim()).filter(Boolean) : undefined,
}));
}
export async function getIptvSeriesCatalog(
tokenId: string,
config: Config,
opts: { genre?: string; search?: string; skip?: number }
) {
const xt = await loadXtreamForToken(tokenId, config);
if (!xt?.line.enableSeries) return [];
const cats = await xt.client.getSeriesCategories().catch(() => []);
const catId = matchCategoryId(cats, opts.genre);
let rows = await xt.client.getSeries(catId);
rows = filterSearch(rows, opts.search);
const skip = opts.skip ?? 0;
const page = rows.slice(skip, skip + PAGE);
return page.map((s: XtreamSeries) => ({
id: seriesId(s.series_id),
type: "series" as const,
name: s.name,
poster: s.cover || undefined,
background: s.backdrop_path?.[0] || undefined,
description: s.plot || undefined,
releaseInfo: s.releaseDate?.slice?.(0, 4) || undefined,
imdbRating: s.rating || undefined,
genres: s.genre ? s.genre.split(",").map((g) => g.trim()).filter(Boolean) : undefined,
}));
}
export async function getIptvLiveMeta(tokenId: string, config: Config, streamId: number) {
const xt = await loadXtreamForToken(tokenId, config);
if (!xt?.line.enableLive) return null;
const streams = await xt.client.getLiveStreams();
const ch = streams.find((s) => s.stream_id === streamId);
if (!ch) return null;
const cats = await xt.client.getLiveCategories().catch(() => []);
const bouquet =
cats.find((c) => String(c.category_id) === String(ch.category_id))?.category_name ?? "Live";
const epg = await xt.client.getShortEpg(streamId, 24);
const now = Date.now();
let nowTitle = "";
let nextTitle = "";
const icon = ch.stream_icon || undefined;
const epgVideos = epg
.map((listing, index) => {
const start = epgToIso(listing, "start");
const end = epgToIso(listing, "end");
if (!start) return null;
const title = decodeEpgText(listing.title) || "Programma";
const overview = decodeEpgText(listing.description) || undefined;
const startMs = Date.parse(start);
const endMs = end ? Date.parse(end) : startMs + 30 * 60_000;
const isNow = startMs <= now && now < endMs;
if (isNow) nowTitle = title;
if (!nextTitle && startMs > now) nextTitle = title;
const runtimeMin = Math.max(1, Math.round((endMs - startMs) / 60_000));
const clock = new Date(startMs).toLocaleTimeString("nl-NL", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone: "Europe/Amsterdam",
});
return {
id: `${liveChannelId(streamId)}:epg:${Math.floor(startMs / 1000)}`,
title: isNow ? `● Nu · ${title}` : `${clock} · ${title}`,
overview,
thumbnail: icon,
released: start,
startTime: start,
endTime: end ?? new Date(endMs).toISOString(),
runtime: `${runtimeMin} min`,
season: 0,
episode: index + 1,
available: true,
};
})
.filter(Boolean) as Array<Record<string, unknown>>;
// Always lead with a Live-entry so Stremio can play immediately.
const liveId = liveChannelId(streamId);
const videos: Array<Record<string, unknown>> = [
{
id: liveId,
title: nowTitle ? `▶ Live — ${nowTitle}` : "▶ Live kijken",
overview: nextTitle ? `Hierna: ${nextTitle}` : bouquet,
thumbnail: icon,
released: new Date(now).toISOString(),
available: true,
season: 0,
episode: 0,
},
...epgVideos,
];
const descriptionParts = [
nowTitle ? `Nu: ${nowTitle}` : null,
nextTitle ? `Straks: ${nextTitle}` : null,
bouquet ? `Bouquet: ${bouquet}` : null,
].filter(Boolean);
return {
id: liveId,
type: "tv" as const,
name: ch.name,
poster: icon,
posterShape: "square" as const,
logo: icon,
background: icon,
description: descriptionParts.join(" · ") || `Live · ${bouquet}`,
genres: [bouquet],
videos,
behaviorHints: {
defaultVideoId: liveId,
hasScheduledVideos: epgVideos.length > 0,
},
};
}
export async function getIptvVodMeta(tokenId: string, config: Config, streamId: number) {
const xt = await loadXtreamForToken(tokenId, config);
if (!xt?.line.enableVod) return null;
const streams = await xt.client.getVodStreams();
const s = streams.find((x) => x.stream_id === streamId);
if (!s) return null;
return {
id: vodId(streamId),
type: "movie" as const,
name: s.name,
poster: s.stream_icon || undefined,
background: s.backdrop_path?.[0] || undefined,
description: s.plot || undefined,
releaseInfo: s.releaseDate?.slice?.(0, 4) || undefined,
runtime: s.duration || undefined,
imdbRating: s.rating || undefined,
cast: s.cast ? s.cast.split(",").map((x) => x.trim()).filter(Boolean) : undefined,
director: s.director ? s.director.split(",").map((x) => x.trim()).filter(Boolean) : undefined,
genres: s.genre ? s.genre.split(",").map((g) => g.trim()).filter(Boolean) : undefined,
};
}
export async function getIptvSeriesMeta(tokenId: string, config: Config, seriesNum: number) {
const xt = await loadXtreamForToken(tokenId, config);
if (!xt?.line.enableSeries) return null;
const info = await xt.client.getSeriesInfo(seriesNum);
const name = info.info?.name ?? `Serie ${seriesNum}`;
const sid = seriesId(seriesNum);
const videos: Array<Record<string, unknown>> = [];
const episodes = info.episodes ?? {};
for (const [seasonKey, eps] of Object.entries(episodes)) {
const seasonNumber = parseInt(seasonKey, 10) || 1;
for (const ep of eps) {
const epNum = ep.episode_num;
const released = ep.info?.releasedate
? new Date(ep.info.releasedate).toISOString()
: new Date(Date.UTC(2000, 0, 1 + seasonNumber * 40 + epNum)).toISOString();
videos.push({
id: `${sid}:${seasonNumber}:${epNum}:${ep.id}`,
title: ep.title || `Episode ${epNum}`,
season: seasonNumber,
episode: epNum,
overview: ep.info?.plot || undefined,
thumbnail: ep.info?.movie_image || undefined,
released: Number.isNaN(Date.parse(released))
? new Date(Date.UTC(2000, 0, seasonNumber * 40 + epNum)).toISOString()
: released,
available: true,
});
}
}
videos.sort(
(a, b) =>
Number(a.season) - Number(b.season) || Number(a.episode) - Number(b.episode)
);
return {
id: sid,
type: "series" as const,
name,
poster: info.info?.cover || undefined,
background: info.info?.backdrop_path?.[0] || undefined,
description: info.info?.plot || undefined,
genres: info.info?.genre
? info.info.genre.split(",").map((g) => g.trim()).filter(Boolean)
: undefined,
cast: info.info?.cast
? info.info.cast.split(",").map((x) => x.trim()).filter(Boolean)
: undefined,
director: info.info?.director
? info.info.director.split(",").map((x) => x.trim()).filter(Boolean)
: undefined,
imdbRating: info.info?.rating || undefined,
videos,
behaviorHints: { hasScheduledVideos: false },
};
}
export async function getIptvStreams(
tokenId: string,
config: Config,
type: string,
id: string
): Promise<Array<{ name: string; title: string; url: string; behaviorHints?: Record<string, unknown> }>> {
const xt = await loadXtreamForToken(tokenId, config);
if (!xt) return [];
const parsed = parseIptvId(id);
if (!parsed) return [];
if (type === "tv" && parsed.kind === "live") {
const urlM3u8 = xt.client.liveStreamUrl(parsed.streamId, "m3u8");
const urlTs = xt.client.liveStreamUrl(parsed.streamId, "ts");
return [
{
name: "Live · HLS",
title: "Xtream m3u8",
url: urlM3u8,
behaviorHints: { notWebReady: true },
},
{
name: "Live · TS",
title: "Xtream mpegts",
url: urlTs,
behaviorHints: { notWebReady: true },
},
];
}
if (type === "movie" && parsed.kind === "vod") {
const streams = await xt.client.getVodStreams();
const s = streams.find((x) => x.stream_id === parsed.streamId);
const ext = s?.container_extension || "mp4";
return [
{
name: `IPTV · ${ext.toUpperCase()}`,
title: s?.name || "VOD",
url: xt.client.vodStreamUrl(parsed.streamId, ext),
behaviorHints: { notWebReady: true },
},
];
}
if (type === "series" && (parsed.kind === "series-ep" || parsed.kind === "series")) {
if (parsed.kind === "series") return [];
const info = await xt.client.getSeriesInfo(parsed.streamId);
const seasonEps = info.episodes?.[String(parsed.season)] ?? [];
const ep =
seasonEps.find((e) => e.id === parsed.episodeStreamId) ||
seasonEps.find((e) => e.episode_num === parsed.episode);
const ext = ep?.container_extension || "mp4";
const epId = ep?.id || parsed.episodeStreamId;
if (!epId) return [];
return [
{
name: `IPTV · S${parsed.season}E${parsed.episode}`,
title: ep?.title || `Episode ${parsed.episode}`,
url: xt.client.seriesStreamUrl(epId, ext),
behaviorHints: { notWebReady: true },
},
];
}
return [];
}
export { parseXtreamBaseUrl, IPTV_PREFIX };

View file

@ -0,0 +1,300 @@
import { decryptSecret } from "../security/crypto";
export type XtreamCredentials = {
baseUrl: string;
username: string;
password: string;
};
export type XtreamCategory = {
category_id: string;
category_name: string;
parent_id?: number;
};
export type XtreamLiveStream = {
num?: number;
name: string;
stream_type?: string;
stream_id: number;
stream_icon?: string;
epg_channel_id?: string;
category_id?: string;
tv_archive?: number;
tv_archive_duration?: number;
};
export type XtreamVodStream = {
num?: number;
name: string;
stream_id: number;
stream_icon?: string;
ranking?: number;
category_id?: string;
container_extension?: string;
rating?: string;
plot?: string;
cast?: string;
director?: string;
genre?: string;
releaseDate?: string;
duration?: string;
backdrop_path?: string[];
};
export type XtreamSeries = {
num?: number;
name: string;
series_id: number;
cover?: string;
plot?: string;
cast?: string;
director?: string;
genre?: string;
releaseDate?: string;
rating?: string;
category_id?: string;
backdrop_path?: string[];
};
export type XtreamEpgListing = {
id?: string;
epg_id?: string;
title: string;
lang?: string;
start: string;
end: string;
description?: string;
channel_id?: string;
start_timestamp?: number;
stop_timestamp?: number;
};
type CacheEntry<T> = { at: number; data: T };
function normalizeBaseUrl(url: string): string {
let u = url.trim().replace(/\/+$/, "");
u = u.replace(/\/player_api\.php$/i, "");
u = u.replace(/\/+$/, "");
if (!/^https?:\/\//i.test(u)) u = `http://${u}`;
return u;
}
export function parseXtreamBaseUrl(raw: string): string {
return normalizeBaseUrl(raw);
}
export class XtreamClient {
private cache = new Map<string, CacheEntry<unknown>>();
readonly baseUrl: string;
readonly username: string;
readonly password: string;
constructor(creds: XtreamCredentials) {
this.baseUrl = normalizeBaseUrl(creds.baseUrl);
this.username = creds.username;
this.password = creds.password;
}
static fromEncrypted(opts: {
baseUrl: string;
username: string;
passwordEnc: string;
sessionSecret: string;
}): XtreamClient {
return new XtreamClient({
baseUrl: opts.baseUrl,
username: opts.username,
password: decryptSecret(opts.passwordEnc, opts.sessionSecret),
});
}
private cacheGet<T>(key: string, ttlMs: number): T | null {
const hit = this.cache.get(key);
if (!hit) return null;
if (Date.now() - hit.at > ttlMs) {
this.cache.delete(key);
return null;
}
return hit.data as T;
}
private cacheSet(key: string, data: unknown) {
this.cache.set(key, { at: Date.now(), data });
}
private apiUrl(params: Record<string, string | number | undefined>): string {
const q = new URLSearchParams({
username: this.username,
password: this.password,
});
for (const [k, v] of Object.entries(params)) {
if (v !== undefined && v !== "") q.set(k, String(v));
}
return `${this.baseUrl}/player_api.php?${q}`;
}
private async getJson<T>(params: Record<string, string | number | undefined>, ttlMs: number): Promise<T> {
const url = this.apiUrl(params);
const cacheKey = url.replace(this.password, "***");
const cached = this.cacheGet<T>(cacheKey, ttlMs);
if (cached) return cached;
const res = await fetch(url, {
headers: { "User-Agent": "MediaCluster/1.0" },
signal: AbortSignal.timeout(20000),
});
if (!res.ok) {
throw new Error(`Xtream API HTTP ${res.status}`);
}
const data = (await res.json()) as T;
this.cacheSet(cacheKey, data);
return data;
}
async authenticate(): Promise<{
status: string;
expDate?: string;
maxConnections?: string;
activeConnections?: string;
}> {
const data = await this.getJson<{
user_info?: {
auth?: number;
status?: string;
exp_date?: string;
max_connections?: string;
active_cons?: string;
};
server_info?: unknown;
}>({}, 30_000);
const ui = data.user_info;
if (!ui || ui.auth === 0) {
throw new Error("Xtream login mislukt (auth=0)");
}
return {
status: ui.status ?? "Unknown",
expDate: ui.exp_date,
maxConnections: ui.max_connections,
activeConnections: ui.active_cons,
};
}
getLiveCategories() {
return this.getJson<XtreamCategory[]>({ action: "get_live_categories" }, 5 * 60_000);
}
getLiveStreams(categoryId?: string) {
return this.getJson<XtreamLiveStream[]>(
{ action: "get_live_streams", category_id: categoryId },
3 * 60_000
);
}
getVodCategories() {
return this.getJson<XtreamCategory[]>({ action: "get_vod_categories" }, 5 * 60_000);
}
getVodStreams(categoryId?: string) {
return this.getJson<XtreamVodStream[]>(
{ action: "get_vod_streams", category_id: categoryId },
3 * 60_000
);
}
getSeriesCategories() {
return this.getJson<XtreamCategory[]>({ action: "get_series_categories" }, 5 * 60_000);
}
getSeries(categoryId?: string) {
return this.getJson<XtreamSeries[]>(
{ action: "get_series", category_id: categoryId },
3 * 60_000
);
}
getSeriesInfo(seriesId: number) {
return this.getJson<{
seasons?: Array<{ air_date?: string; episode_count?: number; name?: string; season_number?: number }>;
info?: XtreamSeries & { plot?: string; cast?: string; director?: string };
episodes?: Record<
string,
Array<{
id: string;
episode_num: number;
title?: string;
container_extension?: string;
info?: { plot?: string; duration_secs?: number; movie_image?: string; releasedate?: string };
}>
>;
}>({ action: "get_series_info", series_id: seriesId }, 5 * 60_000);
}
async getShortEpg(streamId: number, limit = 12): Promise<XtreamEpgListing[]> {
try {
const data = await this.getJson<{ epg_listings?: XtreamEpgListing[] }>(
{ action: "get_short_epg", stream_id: streamId, limit },
60_000
);
if (data.epg_listings?.length) return data.epg_listings;
} catch {
// fall through to simple data table
}
try {
const data = await this.getJson<{ epg_listings?: XtreamEpgListing[] }>(
{ action: "get_simple_data_table", stream_id: streamId },
60_000
);
const rows = data.epg_listings ?? [];
return limit > 0 ? rows.slice(0, limit) : rows;
} catch {
return [];
}
}
liveStreamUrl(streamId: number, ext: "m3u8" | "ts" = "m3u8"): string {
return `${this.baseUrl}/live/${this.username}/${this.password}/${streamId}.${ext}`;
}
vodStreamUrl(streamId: number, ext = "mp4"): string {
return `${this.baseUrl}/movie/${this.username}/${this.password}/${streamId}.${ext}`;
}
seriesStreamUrl(episodeId: string, ext = "mp4"): string {
return `${this.baseUrl}/series/${this.username}/${this.password}/${episodeId}.${ext}`;
}
}
export function decodeEpgText(raw?: string): string {
if (!raw) return "";
try {
// Xtream often base64-encodes title/description
if (/^[A-Za-z0-9+/=]+$/.test(raw) && raw.length % 4 === 0) {
const decoded = Buffer.from(raw, "base64").toString("utf8");
if (decoded && !decoded.includes("\uFFFD")) return decoded;
}
} catch {
// fall through
}
return raw;
}
export function epgToIso(listing: XtreamEpgListing, which: "start" | "end"): string | undefined {
const ts = which === "start" ? listing.start_timestamp : listing.stop_timestamp;
if (ts && Number(ts) > 0) {
return new Date(Number(ts) * 1000).toISOString();
}
const raw = which === "start" ? listing.start : listing.end;
if (!raw) return undefined;
// Formats: "2026-08-25 18:00:00" or "20260825180000 +0000"
const compact = raw.match(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/);
if (compact) {
const iso = `${compact[1]}-${compact[2]}-${compact[3]}T${compact[4]}:${compact[5]}:${compact[6]}Z`;
const d = new Date(iso);
if (!Number.isNaN(d.getTime())) return d.toISOString();
}
const d = new Date(raw.replace(" ", "T") + (raw.includes("Z") || raw.includes("+") ? "" : "Z"));
if (!Number.isNaN(d.getTime())) return d.toISOString();
return undefined;
}

View file

@ -1,4 +1,4 @@
import { createHash, randomBytes, randomUUID } from "crypto"; import { createCipheriv, createDecipheriv, createHash, randomBytes, randomUUID } from "crypto";
import * as argon2 from "argon2"; import * as argon2 from "argon2";
export function hashToken(token: string): string { export function hashToken(token: string): string {
@ -38,3 +38,28 @@ export function redactToken(token: string): string {
if (token.length <= 8) return "[REDACTED]"; if (token.length <= 8) return "[REDACTED]";
return `${token.slice(0, 4)}...[REDACTED]`; return `${token.slice(0, 4)}...[REDACTED]`;
} }
function deriveAesKey(secret: string): Buffer {
return createHash("sha256").update(`mc-iptv:${secret}`).digest();
}
/** Encrypt secret fields (Xtream password) for DB storage. */
export function encryptSecret(plaintext: string, secret: string): string {
const key = deriveAesKey(secret);
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", key, iv);
const enc = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
return Buffer.concat([iv, tag, enc]).toString("base64url");
}
export function decryptSecret(payload: string, secret: string): string {
const key = deriveAesKey(secret);
const buf = Buffer.from(payload, "base64url");
const iv = buf.subarray(0, 12);
const tag = buf.subarray(12, 28);
const data = buf.subarray(28);
const decipher = createDecipheriv("aes-256-gcm", key, iv);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(data), decipher.final()]).toString("utf8");
}

View file

@ -1,9 +1,21 @@
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { prisma } from "../database/client"; import { prisma } from "../database/client";
import { validateAddonToken } from "../auth/routes"; import { resolveAddonToken, validateAddonToken } from "../auth/routes";
import { PlaybackService } from "../playback/service"; import { PlaybackService } from "../playback/service";
import { MetadataService } from "../metadata/service"; import { MetadataService } from "../metadata/service";
import type { Config } from "../config"; import type { Config } from "../config";
import {
IPTV_PREFIX,
buildIptvCatalogs,
getIptvLiveCatalog,
getIptvLiveMeta,
getIptvSeriesCatalog,
getIptvSeriesMeta,
getIptvStreams,
getIptvVodCatalog,
getIptvVodMeta,
parseIptvId,
} from "../iptv/service";
const ID_PREFIX = "mc"; const ID_PREFIX = "mc";
@ -16,29 +28,46 @@ const SEARCH_SKIP_EXTRAS = [
const SKIP_EXTRA = [{ name: "skip", isRequired: false }]; const SKIP_EXTRA = [{ name: "skip", isRequired: false }];
const MANIFEST = { const BASE_CATALOGS = [
id: "com.mediacluster.library",
version: "1.4.5",
name: "Media Cluster",
description: "Private distributed media library",
catalogs: [
{ type: "movie", id: "movies", name: "Films", extra: SEARCH_SKIP_EXTRAS }, { type: "movie", id: "movies", name: "Films", extra: SEARCH_SKIP_EXTRAS },
{ type: "series", id: "series", name: "Series", extra: SEARCH_SKIP_EXTRAS }, { type: "series", id: "series", name: "Series", extra: SEARCH_SKIP_EXTRAS },
{ type: "movie", id: "recent-movies", name: "Recent toegevoegd", extra: SKIP_EXTRA }, { type: "movie", id: "recent-movies", name: "Recent toegevoegd", extra: SKIP_EXTRA },
{ type: "series", id: "recent-series", name: "Recent toegevoegde series", extra: SKIP_EXTRA }, { type: "series", id: "recent-series", name: "Recent toegevoegde series", extra: SKIP_EXTRA },
{ type: "movie", id: "4k-movies", name: "4K films", extra: SEARCH_SKIP_EXTRAS }, { type: "movie", id: "4k-movies", name: "4K films", extra: SEARCH_SKIP_EXTRAS },
], ];
async function buildManifest(tokenId: string | null, config: Config) {
const iptv = tokenId ? await buildIptvCatalogs(tokenId, config) : { catalogs: [], types: [] };
const types = new Set(["movie", "series", ...iptv.types]);
return {
id: "com.mediacluster.library",
version: "1.5.1",
name: "Media Cluster",
description: "Privé bibliotheek + Live TV / IPTV (Xtream) met EPG",
catalogs: [...BASE_CATALOGS, ...iptv.catalogs],
resources: [ resources: [
"catalog", "catalog",
// Meta: never claim tt for series (Cinemeta merge breaks episodes). Movies without {
// imdb use mcmovie-*; movies with imdb use tt and get meta from Cinemeta. name: "meta",
{ name: "meta", types: ["movie", "series"], idPrefixes: ["mc", "sth"] }, types: [...types],
// Streams: also claim tt so local movie files attach to IMDb / Cinemeta detail pages. idPrefixes: ["mc", "sth", IPTV_PREFIX],
{ name: "stream", types: ["movie", "series"], idPrefixes: ["mc", "sth", "tt"] }, },
{
name: "stream",
types: [...types],
idPrefixes: ["mc", "sth", "tt", IPTV_PREFIX],
},
], ],
types: ["movie", "series"], types: [...types],
idPrefixes: ["mc", "sth", "tt"], idPrefixes: ["mc", "sth", "tt", IPTV_PREFIX],
behaviorHints: {
adult: false,
...(iptv.catalogs.some((c) => (c as { type?: string }).type === "tv")
? { configurable: false, configurationRequired: false }
: {}),
},
}; };
}
/** /**
* Movies: prefer IMDb so Stremio uses Cinemeta for detail + our addon for streams. * Movies: prefer IMDb so Stremio uses Cinemeta for detail + our addon for streams.
@ -107,11 +136,11 @@ export async function registerStremioRoutes(app: FastifyInstance, config: Config
app.get("/stremio/:token/manifest.json", async (request, reply) => { app.get("/stremio/:token/manifest.json", async (request, reply) => {
const { token } = request.params as { token: string }; const { token } = request.params as { token: string };
const userId = await validateAddonToken(token); const auth = await resolveAddonToken(token);
if (!userId) { if (!auth) {
return reply.status(401).send({ error: "Invalid addon token" }); return reply.status(401).send({ error: "Invalid addon token" });
} }
return MANIFEST; return buildManifest(auth.tokenId, config);
}); });
// Stremio catalog with extras: /catalog/movie/movies/search=matrix.json // Stremio catalog with extras: /catalog/movie/movies/search=matrix.json
@ -122,12 +151,18 @@ export async function registerStremioRoutes(app: FastifyInstance, config: Config
id: string; id: string;
"*": string; "*": string;
}; };
const userId = await validateAddonToken(token); const auth = await resolveAddonToken(token);
if (!userId) { if (!auth) {
return reply.status(401).send({ error: "Invalid addon token" }); return reply.status(401).send({ error: "Invalid addon token" });
} }
const extra = (request.params as { "*": string })["*"] ?? ""; const extra = (request.params as { "*": string })["*"] ?? "";
const { search, skip } = parseCatalogExtra(extra); const { search, skip, genre } = parseCatalogExtra(extra);
if (id.startsWith("iptv-")) {
return {
metas: await getIptvCatalog(auth.tokenId, config, id, type, { search, skip, genre }),
};
}
if (type === "movie") { if (type === "movie") {
return { metas: await getMovieCatalog(id, config, search, skip) }; return { metas: await getMovieCatalog(id, config, search, skip) };
@ -140,11 +175,15 @@ export async function registerStremioRoutes(app: FastifyInstance, config: Config
app.get("/stremio/:token/catalog/:type/:id.json", async (request, reply) => { app.get("/stremio/:token/catalog/:type/:id.json", async (request, reply) => {
const { token, type, id } = request.params as { token: string; type: string; id: string }; const { token, type, id } = request.params as { token: string; type: string; id: string };
const userId = await validateAddonToken(token); const auth = await resolveAddonToken(token);
if (!userId) { if (!auth) {
return reply.status(401).send({ error: "Invalid addon token" }); return reply.status(401).send({ error: "Invalid addon token" });
} }
if (id.startsWith("iptv-")) {
return { metas: await getIptvCatalog(auth.tokenId, config, id, type, {}) };
}
if (type === "movie") { if (type === "movie") {
return { metas: await getMovieCatalog(id, config) }; return { metas: await getMovieCatalog(id, config) };
} }
@ -156,12 +195,17 @@ export async function registerStremioRoutes(app: FastifyInstance, config: Config
app.get("/stremio/:token/meta/:type/*", async (request, reply) => { app.get("/stremio/:token/meta/:type/*", async (request, reply) => {
const { token, type } = request.params as { token: string; type: string; "*": string }; const { token, type } = request.params as { token: string; type: string; "*": string };
const userId = await validateAddonToken(token); const auth = await resolveAddonToken(token);
if (!userId) { if (!auth) {
return reply.status(401).send({ error: "Invalid addon token" }); return reply.status(401).send({ error: "Invalid addon token" });
} }
const id = paramId((request.params as { "*": string })["*"] ?? ""); const id = paramId((request.params as { "*": string })["*"] ?? "");
if (id.startsWith(IPTV_PREFIX) || type === "tv") {
const meta = await getIptvMeta(auth.tokenId, config, type, id);
return { meta: meta ?? { id, type, name: "Unknown" } };
}
if (type === "movie") { if (type === "movie") {
const meta = await getMovieMeta(id, metadata, request.log); const meta = await getMovieMeta(id, metadata, request.log);
return { meta: meta ?? { id, type: "movie", name: "Unknown" } }; return { meta: meta ?? { id, type: "movie", name: "Unknown" } };
@ -175,12 +219,18 @@ export async function registerStremioRoutes(app: FastifyInstance, config: Config
app.get("/stremio/:token/stream/:type/*", async (request, reply) => { app.get("/stremio/:token/stream/:type/*", async (request, reply) => {
const { token, type } = request.params as { token: string; type: string; "*": string }; const { token, type } = request.params as { token: string; type: string; "*": string };
const userId = await validateAddonToken(token); const auth = await resolveAddonToken(token);
if (!userId) { if (!auth) {
return reply.status(401).send({ error: "Invalid addon token" }); return reply.status(401).send({ error: "Invalid addon token" });
} }
const id = paramId((request.params as { "*": string })["*"] ?? ""); const id = paramId((request.params as { "*": string })["*"] ?? "");
const streams = await getStreams(type, id, userId, playback, metadata, request.log);
if (id.startsWith(IPTV_PREFIX) || type === "tv") {
const streams = await getIptvStreams(auth.tokenId, config, type, id);
return { streams };
}
const streams = await getStreams(type, id, auth.userId, playback, metadata, request.log);
return { streams }; return { streams };
}); });
@ -211,21 +261,57 @@ export async function registerStremioRoutes(app: FastifyInstance, config: Config
}); });
} }
function parseCatalogExtra(extra: string): { search?: string; skip: number } { function parseCatalogExtra(extra: string): { search?: string; skip: number; genre?: string } {
const decoded = decodeURIComponent(extra.replace(/\.json$/, "")); const decoded = decodeURIComponent(extra.replace(/\.json$/, ""));
const parts = decoded.split("&"); const parts = decoded.split("&");
let search: string | undefined; let search: string | undefined;
let genre: string | undefined;
let skip = 0; let skip = 0;
for (const part of parts) { for (const part of parts) {
const [key, ...rest] = part.split("="); const [key, ...rest] = part.split("=");
const value = rest.join("="); const value = rest.join("=");
if (key === "search" && value) search = value; if (key === "search" && value) search = value;
if (key === "genre" && value) genre = value;
if (key === "skip") { if (key === "skip") {
const n = parseInt(value, 10); const n = parseInt(value, 10);
if (!Number.isNaN(n) && n > 0) skip = n; if (!Number.isNaN(n) && n > 0) skip = n;
} }
} }
return { search, skip }; return { search, skip, genre };
}
async function getIptvCatalog(
tokenId: string,
config: Config,
catalogId: string,
type: string,
opts: { search?: string; skip?: number; genre?: string }
) {
if (catalogId === "iptv-live" || type === "tv") {
return getIptvLiveCatalog(tokenId, config, opts);
}
if (catalogId === "iptv-movies") {
return getIptvVodCatalog(tokenId, config, opts);
}
if (catalogId === "iptv-series") {
return getIptvSeriesCatalog(tokenId, config, opts);
}
return [];
}
async function getIptvMeta(tokenId: string, config: Config, type: string, id: string) {
const parsed = parseIptvId(id);
if (!parsed) return null;
if (type === "tv" || parsed.kind === "live") {
return getIptvLiveMeta(tokenId, config, parsed.streamId);
}
if (parsed.kind === "vod") {
return getIptvVodMeta(tokenId, config, parsed.streamId);
}
if (parsed.kind === "series" || parsed.kind === "series-ep") {
return getIptvSeriesMeta(tokenId, config, parsed.streamId);
}
return null;
} }
async function getMovieCatalog( async function getMovieCatalog(