Add Xtream IPTV per addon token with Live TV, EPG, and icons.
This commit is contained in:
parent
b78f7af24f
commit
9b7e26041e
10 changed files with 1552 additions and 46 deletions
|
|
@ -8,8 +8,33 @@ interface AddonTokenRow {
|
|||
label: string | null;
|
||||
revoked: boolean;
|
||||
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";
|
||||
|
||||
export default function SettingsPage() {
|
||||
|
|
@ -18,6 +43,11 @@ export default function SettingsPage() {
|
|||
const [addonToken, setAddonToken] = useState("");
|
||||
const [installUrl, setInstallUrl] = useState("");
|
||||
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(() => {
|
||||
fetch("/api/v1/admin/addon-tokens", { credentials: "include" })
|
||||
|
|
@ -69,9 +99,131 @@ export default function SettingsPage() {
|
|||
method: "DELETE",
|
||||
credentials: "include",
|
||||
});
|
||||
if (iptvTokenId === id) {
|
||||
setIptvTokenId(null);
|
||||
setIptv(emptyIptv());
|
||||
}
|
||||
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 (
|
||||
<>
|
||||
<Nav />
|
||||
|
|
@ -84,7 +236,7 @@ export default function SettingsPage() {
|
|||
</h2>
|
||||
<p style={{ color: "#a1a1aa", marginBottom: "1rem", fontSize: "0.875rem" }}>
|
||||
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>
|
||||
<div className="form-group">
|
||||
<label>Label</label>
|
||||
|
|
@ -104,7 +256,7 @@ export default function SettingsPage() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card" style={{ marginBottom: "2rem" }}>
|
||||
<h2 style={{ fontSize: "1rem", color: "#fafafa", textTransform: "none", marginBottom: "1rem" }}>
|
||||
Bestaande addons
|
||||
</h2>
|
||||
|
|
@ -113,21 +265,38 @@ export default function SettingsPage() {
|
|||
<tr>
|
||||
<th>Label</th>
|
||||
<th>Status</th>
|
||||
<th>IPTV</th>
|
||||
<th>Aangemaakt</th>
|
||||
<th>Acties</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tokens.map((t) => (
|
||||
<tr key={t.id}>
|
||||
<tr key={t.id} style={iptvTokenId === t.id ? { background: "#14161f" } : undefined}>
|
||||
<td>{t.label || "—"}</td>
|
||||
<td>
|
||||
<span className={`status ${t.revoked ? "revoked" : "online"}`}>
|
||||
{t.revoked ? "Inactief" : "Actief"}
|
||||
</span>
|
||||
</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 style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
||||
<button
|
||||
onClick={() => openIptv(t.id)}
|
||||
style={{ background: "#0e7490" }}
|
||||
title="Xtream / XUI userline"
|
||||
>
|
||||
IPTV
|
||||
</button>
|
||||
{t.revoked ? (
|
||||
<button onClick={() => activateToken(t.id)} style={{ background: "#166534" }}>
|
||||
Activeren
|
||||
|
|
@ -145,7 +314,7 @@ export default function SettingsPage() {
|
|||
))}
|
||||
{tokens.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} style={{ textAlign: "center", color: "#71717a" }}>
|
||||
<td colSpan={5} style={{ textAlign: "center", color: "#71717a" }}>
|
||||
Nog geen addons aangemaakt
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -157,6 +326,182 @@ export default function SettingsPage() {
|
|||
De installatie-URL zelf wordt om veiligheidsredenen niet opnieuw getoond.
|
||||
</p>
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -65,10 +65,33 @@ model AddonToken {
|
|||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
iptvLine IptvLine?
|
||||
|
||||
@@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 {
|
||||
id String @id @default(uuid())
|
||||
name String @unique
|
||||
|
|
|
|||
|
|
@ -331,6 +331,7 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
|
|||
const tokens = await prisma.addonToken.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: { iptvLine: { select: { id: true, enableLive: true, lastOkAt: true, lastError: true } } },
|
||||
});
|
||||
return {
|
||||
tokens: tokens.map((t) => ({
|
||||
|
|
@ -338,6 +339,8 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
|
|||
label: t.label,
|
||||
revoked: t.revoked,
|
||||
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;
|
||||
});
|
||||
|
||||
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) => {
|
||||
const body = request.body as {
|
||||
title: string;
|
||||
|
|
|
|||
|
|
@ -91,6 +91,33 @@ async function main() {
|
|||
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.
|
||||
try {
|
||||
await prisma.$executeRawUnsafe(`
|
||||
|
|
|
|||
|
|
@ -121,9 +121,20 @@ export async function ensureAdminUser(config: Config): Promise<void> {
|
|||
}
|
||||
|
||||
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({
|
||||
where: { tokenHash: hashToken(token) },
|
||||
});
|
||||
if (!record || record.revoked) return null;
|
||||
return record.userId;
|
||||
return { userId: record.userId, tokenId: record.id, rawToken: token };
|
||||
}
|
||||
|
|
|
|||
492
apps/master-api/src/iptv/service.ts
Normal file
492
apps/master-api/src/iptv/service.ts
Normal 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 };
|
||||
300
apps/master-api/src/iptv/xtream.ts
Normal file
300
apps/master-api/src/iptv/xtream.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { createHash, randomBytes, randomUUID } from "crypto";
|
||||
import { createCipheriv, createDecipheriv, createHash, randomBytes, randomUUID } from "crypto";
|
||||
import * as argon2 from "argon2";
|
||||
|
||||
export function hashToken(token: string): string {
|
||||
|
|
@ -38,3 +38,28 @@ export function redactToken(token: string): string {
|
|||
if (token.length <= 8) return "[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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,21 @@
|
|||
import type { FastifyInstance } from "fastify";
|
||||
import { prisma } from "../database/client";
|
||||
import { validateAddonToken } from "../auth/routes";
|
||||
import { resolveAddonToken, validateAddonToken } from "../auth/routes";
|
||||
import { PlaybackService } from "../playback/service";
|
||||
import { MetadataService } from "../metadata/service";
|
||||
import type { Config } from "../config";
|
||||
import {
|
||||
IPTV_PREFIX,
|
||||
buildIptvCatalogs,
|
||||
getIptvLiveCatalog,
|
||||
getIptvLiveMeta,
|
||||
getIptvSeriesCatalog,
|
||||
getIptvSeriesMeta,
|
||||
getIptvStreams,
|
||||
getIptvVodCatalog,
|
||||
getIptvVodMeta,
|
||||
parseIptvId,
|
||||
} from "../iptv/service";
|
||||
|
||||
const ID_PREFIX = "mc";
|
||||
|
||||
|
|
@ -16,29 +28,46 @@ const SEARCH_SKIP_EXTRAS = [
|
|||
|
||||
const SKIP_EXTRA = [{ name: "skip", isRequired: false }];
|
||||
|
||||
const MANIFEST = {
|
||||
id: "com.mediacluster.library",
|
||||
version: "1.4.5",
|
||||
name: "Media Cluster",
|
||||
description: "Private distributed media library",
|
||||
catalogs: [
|
||||
const BASE_CATALOGS = [
|
||||
{ type: "movie", id: "movies", name: "Films", 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: "series", id: "recent-series", name: "Recent toegevoegde series", extra: SKIP_EXTRA },
|
||||
{ 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: [
|
||||
"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", types: ["movie", "series"], idPrefixes: ["mc", "sth"] },
|
||||
// Streams: also claim tt so local movie files attach to IMDb / Cinemeta detail pages.
|
||||
{ name: "stream", types: ["movie", "series"], idPrefixes: ["mc", "sth", "tt"] },
|
||||
{
|
||||
name: "meta",
|
||||
types: [...types],
|
||||
idPrefixes: ["mc", "sth", IPTV_PREFIX],
|
||||
},
|
||||
{
|
||||
name: "stream",
|
||||
types: [...types],
|
||||
idPrefixes: ["mc", "sth", "tt", IPTV_PREFIX],
|
||||
},
|
||||
],
|
||||
types: ["movie", "series"],
|
||||
idPrefixes: ["mc", "sth", "tt"],
|
||||
};
|
||||
types: [...types],
|
||||
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.
|
||||
|
|
@ -107,11 +136,11 @@ export async function registerStremioRoutes(app: FastifyInstance, config: Config
|
|||
|
||||
app.get("/stremio/:token/manifest.json", async (request, reply) => {
|
||||
const { token } = request.params as { token: string };
|
||||
const userId = await validateAddonToken(token);
|
||||
if (!userId) {
|
||||
const auth = await resolveAddonToken(token);
|
||||
if (!auth) {
|
||||
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
|
||||
|
|
@ -122,12 +151,18 @@ export async function registerStremioRoutes(app: FastifyInstance, config: Config
|
|||
id: string;
|
||||
"*": string;
|
||||
};
|
||||
const userId = await validateAddonToken(token);
|
||||
if (!userId) {
|
||||
const auth = await resolveAddonToken(token);
|
||||
if (!auth) {
|
||||
return reply.status(401).send({ error: "Invalid addon token" });
|
||||
}
|
||||
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") {
|
||||
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) => {
|
||||
const { token, type, id } = request.params as { token: string; type: string; id: string };
|
||||
const userId = await validateAddonToken(token);
|
||||
if (!userId) {
|
||||
const auth = await resolveAddonToken(token);
|
||||
if (!auth) {
|
||||
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") {
|
||||
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) => {
|
||||
const { token, type } = request.params as { token: string; type: string; "*": string };
|
||||
const userId = await validateAddonToken(token);
|
||||
if (!userId) {
|
||||
const auth = await resolveAddonToken(token);
|
||||
if (!auth) {
|
||||
return reply.status(401).send({ error: "Invalid addon token" });
|
||||
}
|
||||
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") {
|
||||
const meta = await getMovieMeta(id, metadata, request.log);
|
||||
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) => {
|
||||
const { token, type } = request.params as { token: string; type: string; "*": string };
|
||||
const userId = await validateAddonToken(token);
|
||||
if (!userId) {
|
||||
const auth = await resolveAddonToken(token);
|
||||
if (!auth) {
|
||||
return reply.status(401).send({ error: "Invalid addon token" });
|
||||
}
|
||||
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 };
|
||||
});
|
||||
|
||||
|
|
@ -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 parts = decoded.split("&");
|
||||
let search: string | undefined;
|
||||
let genre: string | undefined;
|
||||
let skip = 0;
|
||||
for (const part of parts) {
|
||||
const [key, ...rest] = part.split("=");
|
||||
const value = rest.join("=");
|
||||
if (key === "search" && value) search = value;
|
||||
if (key === "genre" && value) genre = value;
|
||||
if (key === "skip") {
|
||||
const n = parseInt(value, 10);
|
||||
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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue