Polish TV Live Events focus/playback and add per-viewer provider access in admin/API.
Improves Android TV Events navigation and player UX, and gates schedule streams by viewer Live Events ACL plus prisma migration.
This commit is contained in:
parent
ac951617cb
commit
cbacf1af41
14 changed files with 1540 additions and 184 deletions
|
|
@ -164,6 +164,10 @@ export default function SettingsPage() {
|
||||||
const [tvLiveEventsUrl, setTvLiveEventsUrl] = useState(
|
const [tvLiveEventsUrl, setTvLiveEventsUrl] = useState(
|
||||||
"http://epg.faabserver.nl/api/export/live-events?horizon_days=3"
|
"http://epg.faabserver.nl/api/export/live-events?horizon_days=3"
|
||||||
);
|
);
|
||||||
|
/** Providers die uit staan (denylist). Ontbrekende = aan. */
|
||||||
|
const [tvDisabledProviders, setTvDisabledProviders] = useState<string[]>([]);
|
||||||
|
/** Ontdekt uit feed + bekende disabled. */
|
||||||
|
const [tvLiveEventProviders, setTvLiveEventProviders] = useState<string[]>([]);
|
||||||
const [tvShowAlle, setTvShowAlle] = useState(true);
|
const [tvShowAlle, setTvShowAlle] = useState(true);
|
||||||
const [tvCategories, setTvCategories] = useState<
|
const [tvCategories, setTvCategories] = useState<
|
||||||
Array<{ id: string; name: string; channelCount: number }>
|
Array<{ id: string; name: string; channelCount: number }>
|
||||||
|
|
@ -262,8 +266,18 @@ export default function SettingsPage() {
|
||||||
? s.liveEventsUrl
|
? s.liveEventsUrl
|
||||||
: "http://epg.faabserver.nl/api/export/live-events?horizon_days=3"
|
: "http://epg.faabserver.nl/api/export/live-events?horizon_days=3"
|
||||||
);
|
);
|
||||||
|
setTvDisabledProviders(
|
||||||
|
Array.isArray(s.liveEventsDisabledProviders)
|
||||||
|
? s.liveEventsDisabledProviders.map((p: string) => String(p).toLowerCase())
|
||||||
|
: []
|
||||||
|
);
|
||||||
}
|
}
|
||||||
setTvCategories(Array.isArray(d.categories) ? d.categories : []);
|
setTvCategories(Array.isArray(d.categories) ? d.categories : []);
|
||||||
|
setTvLiveEventProviders(
|
||||||
|
Array.isArray(d.liveEventProviders)
|
||||||
|
? d.liveEventProviders.map((p: string) => String(p).toLowerCase())
|
||||||
|
: []
|
||||||
|
);
|
||||||
})
|
})
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -568,6 +582,7 @@ export default function SettingsPage() {
|
||||||
categoryOrder: tvCategories.map((c) => c.id),
|
categoryOrder: tvCategories.map((c) => c.id),
|
||||||
showAlleZenders: tvShowAlle,
|
showAlleZenders: tvShowAlle,
|
||||||
liveEventsUrl: tvLiveEventsUrl.trim(),
|
liveEventsUrl: tvLiveEventsUrl.trim(),
|
||||||
|
liveEventsDisabledProviders: tvDisabledProviders,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
|
|
@ -586,8 +601,18 @@ export default function SettingsPage() {
|
||||||
? data.settings.liveEventsUrl
|
? data.settings.liveEventsUrl
|
||||||
: ""
|
: ""
|
||||||
);
|
);
|
||||||
|
setTvDisabledProviders(
|
||||||
|
Array.isArray(data.settings.liveEventsDisabledProviders)
|
||||||
|
? data.settings.liveEventsDisabledProviders.map((p: string) => String(p).toLowerCase())
|
||||||
|
: []
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (Array.isArray(data.categories)) setTvCategories(data.categories);
|
if (Array.isArray(data.categories)) setTvCategories(data.categories);
|
||||||
|
if (Array.isArray(data.liveEventProviders)) {
|
||||||
|
setTvLiveEventProviders(
|
||||||
|
data.liveEventProviders.map((p: string) => String(p).toLowerCase())
|
||||||
|
);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setTvBusy(false);
|
setTvBusy(false);
|
||||||
}
|
}
|
||||||
|
|
@ -936,7 +961,7 @@ export default function SettingsPage() {
|
||||||
JSON-export (FAAB) voor de Events-tab in Android TV. Leeg laten schakelt de tab uit.
|
JSON-export (FAAB) voor de Events-tab in Android TV. Leeg laten schakelt de tab uit.
|
||||||
Default: FAAB live-events met 3 dagen horizon.
|
Default: FAAB live-events met 3 dagen horizon.
|
||||||
</p>
|
</p>
|
||||||
<label style={{ display: "block" }}>
|
<label style={{ display: "block", marginBottom: "0.75rem" }}>
|
||||||
Feed-URL
|
Feed-URL
|
||||||
<input
|
<input
|
||||||
type="url"
|
type="url"
|
||||||
|
|
@ -947,6 +972,45 @@ export default function SettingsPage() {
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<h4 style={{ fontSize: "0.95rem", margin: "0.5rem 0 0.35rem" }}>
|
||||||
|
Providers (cluster-breed)
|
||||||
|
</h4>
|
||||||
|
<p className="muted" style={{ marginBottom: "0.5rem" }}>
|
||||||
|
Vink aan welke providers zichtbaar zijn. Nieuwe providers die nog niet in de lijst
|
||||||
|
stonden blijven standaard aan, zodat de feed niet stilvalt.
|
||||||
|
</p>
|
||||||
|
{tvLiveEventProviders.length === 0 ? (
|
||||||
|
<p className="muted">
|
||||||
|
Nog geen providers ontdekt — controleer de feed-URL en sla op / herlaad.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="check-rows">
|
||||||
|
{tvLiveEventProviders.map((p) => {
|
||||||
|
const enabled = !tvDisabledProviders.includes(p);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={p}
|
||||||
|
className="check-row"
|
||||||
|
style={{ display: "flex", gap: "0.6rem", alignItems: "center" }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={enabled}
|
||||||
|
onChange={(e) => {
|
||||||
|
setTvDisabledProviders((prev) => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
return prev.filter((x) => x !== p);
|
||||||
|
}
|
||||||
|
return prev.includes(p) ? prev : [...prev, p];
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span style={{ fontFamily: "monospace", fontSize: "0.9rem" }}>{p}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,9 @@ type Viewer = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
appAccess: boolean;
|
appAccess: boolean;
|
||||||
iptvAccess: boolean;
|
iptvAccess: boolean;
|
||||||
|
liveEventsEnabled: boolean;
|
||||||
|
/** null = alle globaal-enabled providers */
|
||||||
|
liveEventsProviders: string[] | null;
|
||||||
iptvAddonTokenId: string | null;
|
iptvAddonTokenId: string | null;
|
||||||
liveListIds?: string[];
|
liveListIds?: string[];
|
||||||
liveLists?: Array<{ id: string; name: string; enabled: boolean }>;
|
liveLists?: Array<{ id: string; name: string; enabled: boolean }>;
|
||||||
|
|
@ -179,6 +182,15 @@ export default function ViewersPage() {
|
||||||
const [editEmails, setEditEmails] = useState<Record<string, string>>({});
|
const [editEmails, setEditEmails] = useState<Record<string, string>>({});
|
||||||
const [allLiveLists, setAllLiveLists] = useState<LiveListOption[]>([]);
|
const [allLiveLists, setAllLiveLists] = useState<LiveListOption[]>([]);
|
||||||
const [liveListDrafts, setLiveListDrafts] = useState<Record<string, string[]>>({});
|
const [liveListDrafts, setLiveListDrafts] = useState<Record<string, string[]>>({});
|
||||||
|
/** Globaal beschikbare providers (minus disabled) voor per-user multi-select. */
|
||||||
|
const [globalEventProviders, setGlobalEventProviders] = useState<string[]>([]);
|
||||||
|
/**
|
||||||
|
* Draft per viewer: null = alle providers; string[] = subset.
|
||||||
|
* undefined in map = nog niet geïnitialiseerd → val terug op viewer.liveEventsProviders.
|
||||||
|
*/
|
||||||
|
const [eventProviderDrafts, setEventProviderDrafts] = useState<
|
||||||
|
Record<string, string[] | null>
|
||||||
|
>({});
|
||||||
|
|
||||||
const load = useCallback(() => {
|
const load = useCallback(() => {
|
||||||
fetch("/api/v1/admin/viewers", { credentials: "include" })
|
fetch("/api/v1/admin/viewers", { credentials: "include" })
|
||||||
|
|
@ -200,6 +212,15 @@ export default function ViewersPage() {
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
setEventProviderDrafts((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
for (const v of list) {
|
||||||
|
if (next[v.id] === undefined) {
|
||||||
|
next[v.id] = v.liveEventsProviders ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
})
|
})
|
||||||
.catch(() => setErr("Kon gebruikers niet laden"));
|
.catch(() => setErr("Kon gebruikers niet laden"));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -214,6 +235,20 @@ export default function ViewersPage() {
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((d) => setAllLiveLists(d.lists ?? []))
|
.then((d) => setAllLiveLists(d.lists ?? []))
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
|
fetch("/api/v1/admin/settings/iptv", { credentials: "include" })
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((d) => {
|
||||||
|
const all: string[] = Array.isArray(d.liveEventProviders)
|
||||||
|
? d.liveEventProviders.map((p: string) => String(p).toLowerCase())
|
||||||
|
: [];
|
||||||
|
const disabled = new Set(
|
||||||
|
Array.isArray(d.settings?.liveEventsDisabledProviders)
|
||||||
|
? d.settings.liveEventsDisabledProviders.map((p: string) => String(p).toLowerCase())
|
||||||
|
: []
|
||||||
|
);
|
||||||
|
setGlobalEventProviders(all.filter((p) => !disabled.has(p)));
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
// Live updates via SSE (device link, login lock, CRUD). Geen interval-poll.
|
// Live updates via SSE (device link, login lock, CRUD). Geen interval-poll.
|
||||||
|
|
@ -339,15 +374,20 @@ export default function ViewersPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggle(v: Viewer, field: "enabled" | "appAccess" | "iptvAccess") {
|
async function toggle(
|
||||||
|
v: Viewer,
|
||||||
|
field: "enabled" | "appAccess" | "iptvAccess" | "liveEventsEnabled"
|
||||||
|
) {
|
||||||
setActionId(v.id);
|
setActionId(v.id);
|
||||||
setErr(null);
|
setErr(null);
|
||||||
try {
|
try {
|
||||||
|
const current =
|
||||||
|
field === "liveEventsEnabled" ? v.liveEventsEnabled !== false : Boolean(v[field]);
|
||||||
const res = await fetch(`/api/v1/admin/viewers/${v.id}`, {
|
const res = await fetch(`/api/v1/admin/viewers/${v.id}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ [field]: !v[field] }),
|
body: JSON.stringify({ [field]: !current }),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
|
|
@ -359,6 +399,63 @@ export default function ViewersPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleEventProviderDraft(viewerId: string, provider: string) {
|
||||||
|
setEventProviderDrafts((prev) => {
|
||||||
|
const cur = prev[viewerId];
|
||||||
|
// null = all → start from full global list then toggle off
|
||||||
|
const base =
|
||||||
|
cur === null || cur === undefined
|
||||||
|
? [...globalEventProviders]
|
||||||
|
: [...cur];
|
||||||
|
const next = base.includes(provider)
|
||||||
|
? base.filter((p) => p !== provider)
|
||||||
|
: [...base, provider];
|
||||||
|
// Empty allowlist is stored as "all" — use Live Events toggle for none
|
||||||
|
if (next.length === 0) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
// Selecting all again → store null (all)
|
||||||
|
if (
|
||||||
|
next.length === globalEventProviders.length &&
|
||||||
|
globalEventProviders.every((p) => next.includes(p))
|
||||||
|
) {
|
||||||
|
return { ...prev, [viewerId]: null };
|
||||||
|
}
|
||||||
|
return { ...prev, [viewerId]: next };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEventProviders(v: Viewer) {
|
||||||
|
setActionId(v.id);
|
||||||
|
setMsg(null);
|
||||||
|
setErr(null);
|
||||||
|
try {
|
||||||
|
const draft =
|
||||||
|
eventProviderDrafts[v.id] !== undefined
|
||||||
|
? eventProviderDrafts[v.id]
|
||||||
|
: v.liveEventsProviders ?? null;
|
||||||
|
const res = await fetch(`/api/v1/admin/viewers/${v.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ liveEventsProviders: draft }),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) {
|
||||||
|
setErr(data.error?.message ?? "Providers opslaan mislukt");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEventProviderDrafts((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[v.id]: data.viewer?.liveEventsProviders ?? draft,
|
||||||
|
}));
|
||||||
|
setMsg("Live Events-providers opgeslagen");
|
||||||
|
load();
|
||||||
|
} finally {
|
||||||
|
setActionId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function saveIptv(v: Viewer) {
|
async function saveIptv(v: Viewer) {
|
||||||
const form = iptvForms[v.id] ?? emptyIptvForm();
|
const form = iptvForms[v.id] ?? emptyIptvForm();
|
||||||
setActionId(v.id);
|
setActionId(v.id);
|
||||||
|
|
@ -1229,6 +1326,21 @@ export default function ViewersPage() {
|
||||||
<span />
|
<span />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="toggle-row">
|
||||||
|
<div>
|
||||||
|
<strong>Live Events</strong>
|
||||||
|
<span className="muted">Events-tab in Android TV</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`switch ${selected.liveEventsEnabled !== false ? "on" : ""}`}
|
||||||
|
disabled={actionId === selected.id || !selected.appAccess}
|
||||||
|
onClick={() => void toggle(selected, "liveEventsEnabled")}
|
||||||
|
aria-pressed={selected.liveEventsEnabled !== false}
|
||||||
|
>
|
||||||
|
<span />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="detail-actions">
|
<div className="detail-actions">
|
||||||
<button
|
<button
|
||||||
|
|
@ -1409,6 +1521,77 @@ export default function ViewersPage() {
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
<article className="block">
|
||||||
|
<header className="block-head">
|
||||||
|
<h3>Live Events-providers</h3>
|
||||||
|
</header>
|
||||||
|
<p className="muted detail-copy">
|
||||||
|
Beperk welke providers deze gebruiker ziet. Alles aangevinkt (of knop
|
||||||
|
"Alle providers") = alle globaal ingeschakelde providers.
|
||||||
|
Cluster-breed uitgeschakelde providers staan hier niet. Voor geen
|
||||||
|
events: zet Live Events hierboven uit.
|
||||||
|
</p>
|
||||||
|
{globalEventProviders.length === 0 ? (
|
||||||
|
<p className="muted">
|
||||||
|
Nog geen providers — configureer de Live Events-feed onder{" "}
|
||||||
|
<Link href="/settings">Instellingen → TV</Link>.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="check-rows">
|
||||||
|
{globalEventProviders.map((p) => {
|
||||||
|
const draft =
|
||||||
|
eventProviderDrafts[selected.id] !== undefined
|
||||||
|
? eventProviderDrafts[selected.id]
|
||||||
|
: selected.liveEventsProviders ?? null;
|
||||||
|
const checked = draft === null || (draft?.includes(p) ?? false);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={p}
|
||||||
|
className="field"
|
||||||
|
style={{ display: "flex", gap: 8, alignItems: "center" }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
disabled={
|
||||||
|
actionId === selected.id ||
|
||||||
|
selected.liveEventsEnabled === false
|
||||||
|
}
|
||||||
|
onChange={() => toggleEventProviderDraft(selected.id, p)}
|
||||||
|
/>
|
||||||
|
<span style={{ fontFamily: "monospace", fontSize: "0.9rem" }}>
|
||||||
|
{p}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="detail-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-primary"
|
||||||
|
disabled={
|
||||||
|
actionId === selected.id ||
|
||||||
|
globalEventProviders.length === 0 ||
|
||||||
|
selected.liveEventsEnabled === false
|
||||||
|
}
|
||||||
|
onClick={() => void saveEventProviders(selected)}
|
||||||
|
>
|
||||||
|
Providers opslaan
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={actionId === selected.id}
|
||||||
|
onClick={() =>
|
||||||
|
setEventProviderDrafts((prev) => ({ ...prev, [selected.id]: null }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Alle providers
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
<article className="block">
|
<article className="block">
|
||||||
<header className="block-head">
|
<header className="block-head">
|
||||||
<h3>Stremio-addon</h3>
|
<h3>Stremio-addon</h3>
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,8 @@ android {
|
||||||
applicationId = "nl.vonas.mediacluster.tv"
|
applicationId = "nl.vonas.mediacluster.tv"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 114
|
versionCode = 117
|
||||||
versionName = "0.14.51"
|
versionName = "0.14.54"
|
||||||
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
|
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
|
||||||
ndk {
|
ndk {
|
||||||
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
|
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
|
||||||
|
|
|
||||||
|
|
@ -426,8 +426,10 @@ private fun hexToBytes(hex: String): ByteArray {
|
||||||
.replace("-", "")
|
.replace("-", "")
|
||||||
.replace(Regex("[\\s:]"), "")
|
.replace(Regex("[\\s:]"), "")
|
||||||
.lowercase()
|
.lowercase()
|
||||||
require(clean.length == 32 && clean.all { it in "0123456789abcdef" }) {
|
if (clean.length != 32 || clean.any { it !in "0123456789abcdef" }) {
|
||||||
"ongeldige ClearKey hex (verwacht 32 tekens): ${hex.take(20)}"
|
throw IllegalArgumentException(
|
||||||
|
"ongeldige ClearKey hex (verwacht 32 tekens): ${hex.take(20)}",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return ByteArray(16) { i ->
|
return ByteArray(16) { i ->
|
||||||
clean.substring(i * 2, i * 2 + 2).toInt(16).toByte()
|
clean.substring(i * 2, i * 2 + 2).toInt(16).toByte()
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import android.view.ViewGroup
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.focusable
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
|
@ -17,7 +18,14 @@ import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
|
import androidx.compose.ui.input.key.key
|
||||||
|
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||||
|
import androidx.compose.ui.input.key.type
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
@ -26,15 +34,22 @@ import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.media3.common.PlaybackException
|
import androidx.media3.common.PlaybackException
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
import androidx.media3.ui.PlayerView
|
import androidx.media3.ui.PlayerView
|
||||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
import nl.vonas.mediacluster.tv.data.ApiClient
|
import nl.vonas.mediacluster.tv.data.ApiClient
|
||||||
import nl.vonas.mediacluster.tv.playback.buildLiveTvPlayer
|
import nl.vonas.mediacluster.tv.playback.buildLiveTvPlayer
|
||||||
import nl.vonas.mediacluster.tv.playback.ensureAudioSelected
|
import nl.vonas.mediacluster.tv.playback.ensureAudioSelected
|
||||||
import nl.vonas.mediacluster.tv.playback.liveMediaItem
|
import nl.vonas.mediacluster.tv.playback.liveMediaItem
|
||||||
import nl.vonas.mediacluster.tv.ui.theme.McColors
|
import nl.vonas.mediacluster.tv.ui.theme.McColors
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
import java.util.concurrent.atomic.AtomicReference
|
||||||
|
|
||||||
|
private const val ReadyTimeoutMs = 20_000L
|
||||||
|
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
@kotlin.OptIn(ExperimentalTvMaterial3Api::class)
|
@kotlin.OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
|
|
@ -45,28 +60,78 @@ fun EventPlayerScreen(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val player = remember { buildLiveTvPlayer(context) }
|
val playerRef = remember { AtomicReference<ExoPlayer?>(null) }
|
||||||
|
val player = remember {
|
||||||
|
buildLiveTvPlayer(context).also { playerRef.set(it) }
|
||||||
|
}
|
||||||
|
val rootFocus = remember { FocusRequester() }
|
||||||
|
val reachedReady = remember { AtomicBoolean(false) }
|
||||||
var title by remember { mutableStateOf("Live event") }
|
var title by remember { mutableStateOf("Live event") }
|
||||||
var loading by remember { mutableStateOf(true) }
|
var loading by remember { mutableStateOf(true) }
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
var showChrome by remember { mutableStateOf(true) }
|
var showChrome by remember { mutableStateOf(true) }
|
||||||
|
|
||||||
|
fun safeStopAndClear() {
|
||||||
|
runCatching {
|
||||||
|
player.playWhenReady = false
|
||||||
|
player.stop()
|
||||||
|
player.clearMediaItems()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(eventId) {
|
LaunchedEffect(eventId) {
|
||||||
loading = true
|
loading = true
|
||||||
error = null
|
error = null
|
||||||
|
reachedReady.set(false)
|
||||||
|
showChrome = true
|
||||||
try {
|
try {
|
||||||
val play = api.scheduleEventPlay(eventId)
|
val play = api.scheduleEventPlay(eventId)
|
||||||
|
if (!isActive) return@LaunchedEffect
|
||||||
title = play.name
|
title = play.name
|
||||||
player.setMediaItem(liveMediaItem(play.streamUrl, play.format, play.drm))
|
val url = play.streamUrl.trim()
|
||||||
player.prepare()
|
if (url.isEmpty()) {
|
||||||
player.playWhenReady = true
|
error = "Geen stream-URL beschikbaar"
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
if (play.drm == null || play.drm.keys.isEmpty()) {
|
||||||
|
error = "Encryptiesleutels ontbreken — stream start binnenkort"
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
val item = try {
|
||||||
|
liveMediaItem(url, play.format, play.drm)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
error = e.message ?: "Ongeldige DRM-sleutels"
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
if (!isActive) return@LaunchedEffect
|
||||||
|
try {
|
||||||
|
player.setMediaItem(item)
|
||||||
|
player.prepare()
|
||||||
|
player.playWhenReady = true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
error = e.message ?: "Kon speler niet starten"
|
||||||
|
safeStopAndClear()
|
||||||
|
}
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
error = e.message ?: "Kon event niet afspelen"
|
error = e.message ?: "Kon event niet afspelen"
|
||||||
|
safeStopAndClear()
|
||||||
} finally {
|
} finally {
|
||||||
loading = false
|
loading = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Geen eindeloze zwart-scherm hang: toon fout als READY uitblijft.
|
||||||
|
LaunchedEffect(eventId, error, loading) {
|
||||||
|
if (error != null || loading) return@LaunchedEffect
|
||||||
|
delay(ReadyTimeoutMs)
|
||||||
|
if (!reachedReady.get() && error == null) {
|
||||||
|
error = "Stream start niet (timeout). Druk op Terug."
|
||||||
|
safeStopAndClear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(showChrome, error, loading) {
|
LaunchedEffect(showChrome, error, loading) {
|
||||||
if (showChrome && error == null && !loading) {
|
if (showChrome && error == null && !loading) {
|
||||||
delay(4_000)
|
delay(4_000)
|
||||||
|
|
@ -74,20 +139,45 @@ fun EventPlayerScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
delay(80)
|
||||||
|
runCatching { rootFocus.requestFocus() }
|
||||||
|
}
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
val listener = object : Player.Listener {
|
val listener = object : Player.Listener {
|
||||||
override fun onPlaybackStateChanged(state: Int) {
|
override fun onPlaybackStateChanged(state: Int) {
|
||||||
if (state == Player.STATE_READY) player.ensureAudioSelected()
|
when (state) {
|
||||||
|
Player.STATE_READY -> {
|
||||||
|
reachedReady.set(true)
|
||||||
|
runCatching { player.ensureAudioSelected() }
|
||||||
|
}
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPlayerError(e: PlaybackException) {
|
override fun onPlayerError(e: PlaybackException) {
|
||||||
error = e.message ?: "Afspeelfout"
|
val cause = generateSequence(e as Throwable?) { it.cause }
|
||||||
|
.mapNotNull { it.message?.takeIf { m -> m.isNotBlank() } }
|
||||||
|
.distinct()
|
||||||
|
.take(3)
|
||||||
|
.joinToString(" → ")
|
||||||
|
error = listOfNotNull(e.errorCodeName, cause.takeIf { it.isNotBlank() })
|
||||||
|
.joinToString(": ")
|
||||||
|
.ifBlank { e.message ?: "Afspeelfout" }
|
||||||
|
safeStopAndClear()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
player.addListener(listener)
|
player.addListener(listener)
|
||||||
onDispose {
|
onDispose {
|
||||||
player.removeListener(listener)
|
player.removeListener(listener)
|
||||||
player.release()
|
runCatching {
|
||||||
|
player.playWhenReady = false
|
||||||
|
player.stop()
|
||||||
|
player.clearMediaItems()
|
||||||
|
}
|
||||||
|
runCatching { player.release() }
|
||||||
|
playerRef.compareAndSet(player, null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -96,22 +186,38 @@ fun EventPlayerScreen(
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(Color.Black),
|
.background(Color.Black)
|
||||||
) {
|
.focusRequester(rootFocus)
|
||||||
AndroidView(
|
.focusable()
|
||||||
factory = { ctx ->
|
.onPreviewKeyEvent { ev ->
|
||||||
PlayerView(ctx).apply {
|
if (ev.type == KeyEventType.KeyDown &&
|
||||||
useController = false
|
(ev.key == Key.Back || ev.key == Key.Escape)
|
||||||
layoutParams = ViewGroup.LayoutParams(
|
) {
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
onBack()
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
true
|
||||||
)
|
} else {
|
||||||
this.player = player
|
false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
update = { it.player = player },
|
) {
|
||||||
modifier = Modifier.fillMaxSize(),
|
if (error == null) {
|
||||||
)
|
AndroidView(
|
||||||
|
factory = { ctx ->
|
||||||
|
PlayerView(ctx).apply {
|
||||||
|
useController = false
|
||||||
|
layoutParams = ViewGroup.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
)
|
||||||
|
this.player = playerRef.get()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
update = { view ->
|
||||||
|
view.player = playerRef.get()
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
|
@ -121,9 +227,24 @@ fun EventPlayerScreen(
|
||||||
if (error != null) {
|
if (error != null) {
|
||||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
Text("Kan event niet afspelen", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 22.sp)
|
Text(
|
||||||
Text(error!!, color = McColors.Danger, fontSize = 15.sp, modifier = Modifier.padding(top = 8.dp))
|
"Kan event niet afspelen",
|
||||||
Text("Druk op Terug", color = McColors.TextDim, fontSize = 14.sp, modifier = Modifier.padding(top = 12.dp))
|
color = Color.White,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 22.sp,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
error!!,
|
||||||
|
color = McColors.Danger,
|
||||||
|
fontSize = 15.sp,
|
||||||
|
modifier = Modifier.padding(top = 8.dp, start = 32.dp, end = 32.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Druk op Terug",
|
||||||
|
color = McColors.TextDim,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
modifier = Modifier.padding(top = 12.dp),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (showChrome) {
|
} else if (showChrome) {
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -128,6 +128,9 @@ fun McNav() {
|
||||||
var tvCategoryId by rememberSaveable { mutableStateOf<String?>(null) }
|
var tvCategoryId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
var tvLastStreamId by rememberSaveable { mutableStateOf<String?>(null) }
|
var tvLastStreamId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
var tvFocusNonce by remember { mutableIntStateOf(0) }
|
var tvFocusNonce by remember { mutableIntStateOf(0) }
|
||||||
|
var eventsReturnNonce by remember { mutableIntStateOf(0) }
|
||||||
|
var lastOpenedEventId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
|
var pendingEventsFocusRestore by remember { mutableStateOf(false) }
|
||||||
val favoriteIds by store.favoriteStreamIds.collectAsState(initial = emptySet())
|
val favoriteIds by store.favoriteStreamIds.collectAsState(initial = emptySet())
|
||||||
val hubTab = remember(hubTabName) {
|
val hubTab = remember(hubTabName) {
|
||||||
MainTab.entries.firstOrNull { it.name == hubTabName } ?: MainTab.Home
|
MainTab.entries.firstOrNull { it.name == hubTabName } ?: MainTab.Home
|
||||||
|
|
@ -321,6 +324,10 @@ fun McNav() {
|
||||||
onToggleFavorite = { id -> scope.launch { store.toggleFavorite(id) } },
|
onToggleFavorite = { id -> scope.launch { store.toggleFavorite(id) } },
|
||||||
tvFocusNonce = tvFocusNonce,
|
tvFocusNonce = tvFocusNonce,
|
||||||
onTvFocusContent = { tvFocusNonce++ },
|
onTvFocusContent = { tvFocusNonce++ },
|
||||||
|
eventsReturnNonce = eventsReturnNonce,
|
||||||
|
restoreEventId = lastOpenedEventId,
|
||||||
|
pendingEventsFocusRestore = pendingEventsFocusRestore,
|
||||||
|
onEventsFocusRestoreConsumed = { pendingEventsFocusRestore = false },
|
||||||
profileAvatarId = profileAvatarId,
|
profileAvatarId = profileAvatarId,
|
||||||
profileShowAvatar = profileShowAvatar,
|
profileShowAvatar = profileShowAvatar,
|
||||||
profileAvatarUrl = profileAvatarUrl,
|
profileAvatarUrl = profileAvatarUrl,
|
||||||
|
|
@ -342,10 +349,14 @@ fun McNav() {
|
||||||
nav.navigate("live/${android.net.Uri.encode(streamId)}")
|
nav.navigate("live/${android.net.Uri.encode(streamId)}")
|
||||||
},
|
},
|
||||||
onOpenEvent = { eventId ->
|
onOpenEvent = { eventId ->
|
||||||
|
lastOpenedEventId = eventId
|
||||||
nav.navigate("event/${android.net.Uri.encode(eventId)}")
|
nav.navigate("event/${android.net.Uri.encode(eventId)}")
|
||||||
},
|
},
|
||||||
onPlayEvent = { eventId ->
|
onPlayEvent = { eventId ->
|
||||||
nav.navigate("event_play/${android.net.Uri.encode(eventId)}")
|
lastOpenedEventId = eventId
|
||||||
|
nav.navigate("event_play/${android.net.Uri.encode(eventId)}") {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onSwitchProfile = {
|
onSwitchProfile = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
|
@ -476,12 +487,21 @@ fun McNav() {
|
||||||
val eventId = entry.arguments?.getString("eventId")
|
val eventId = entry.arguments?.getString("eventId")
|
||||||
?.let { android.net.Uri.decode(it) }
|
?.let { android.net.Uri.decode(it) }
|
||||||
?: return@composable
|
?: return@composable
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose {
|
||||||
|
pendingEventsFocusRestore = true
|
||||||
|
eventsReturnNonce++
|
||||||
|
}
|
||||||
|
}
|
||||||
EventDetailScreen(
|
EventDetailScreen(
|
||||||
api = api,
|
api = api,
|
||||||
eventId = eventId,
|
eventId = eventId,
|
||||||
onBack = { nav.popBackStack() },
|
onBack = { nav.popBackStack() },
|
||||||
onPlay = { id ->
|
onPlay = { id ->
|
||||||
nav.navigate("event_play/${android.net.Uri.encode(id)}")
|
lastOpenedEventId = id
|
||||||
|
nav.navigate("event_play/${android.net.Uri.encode(id)}") {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -492,6 +512,12 @@ fun McNav() {
|
||||||
val eventId = entry.arguments?.getString("eventId")
|
val eventId = entry.arguments?.getString("eventId")
|
||||||
?.let { android.net.Uri.decode(it) }
|
?.let { android.net.Uri.decode(it) }
|
||||||
?: return@composable
|
?: return@composable
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose {
|
||||||
|
pendingEventsFocusRestore = true
|
||||||
|
eventsReturnNonce++
|
||||||
|
}
|
||||||
|
}
|
||||||
EventPlayerScreen(
|
EventPlayerScreen(
|
||||||
api = api,
|
api = api,
|
||||||
eventId = eventId,
|
eventId = eventId,
|
||||||
|
|
@ -519,6 +545,10 @@ private fun MainHub(
|
||||||
onToggleFavorite: (String) -> Unit,
|
onToggleFavorite: (String) -> Unit,
|
||||||
tvFocusNonce: Int,
|
tvFocusNonce: Int,
|
||||||
onTvFocusContent: () -> Unit,
|
onTvFocusContent: () -> Unit,
|
||||||
|
eventsReturnNonce: Int = 0,
|
||||||
|
restoreEventId: String? = null,
|
||||||
|
pendingEventsFocusRestore: Boolean = false,
|
||||||
|
onEventsFocusRestoreConsumed: () -> Unit = {},
|
||||||
profileAvatarId: String?,
|
profileAvatarId: String?,
|
||||||
profileShowAvatar: Boolean = true,
|
profileShowAvatar: Boolean = true,
|
||||||
profileAvatarUrl: String? = null,
|
profileAvatarUrl: String? = null,
|
||||||
|
|
@ -544,6 +574,7 @@ private fun MainHub(
|
||||||
var coldStartNavFocus by rememberSaveable { mutableStateOf(true) }
|
var coldStartNavFocus by rememberSaveable { mutableStateOf(true) }
|
||||||
val navFocus = remember { FocusRequester() }
|
val navFocus = remember { FocusRequester() }
|
||||||
val homeContentFocus = remember { FocusRequester() }
|
val homeContentFocus = remember { FocusRequester() }
|
||||||
|
val eventsContentFocus = remember { FocusRequester() }
|
||||||
val exitFocus = remember { FocusRequester() }
|
val exitFocus = remember { FocusRequester() }
|
||||||
var exitReady by remember { mutableStateOf(false) }
|
var exitReady by remember { mutableStateOf(false) }
|
||||||
var prevTabName by rememberSaveable { mutableStateOf<String?>(null) }
|
var prevTabName by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
|
|
@ -733,10 +764,18 @@ private fun MainHub(
|
||||||
exitConfirm = false
|
exitConfirm = false
|
||||||
homeChromeCompact = false
|
homeChromeCompact = false
|
||||||
when (it) {
|
when (it) {
|
||||||
MainTab.Tv, MainTab.TvGuide, MainTab.Events -> {
|
MainTab.Tv, MainTab.TvGuide -> {
|
||||||
navFocused = false
|
navFocused = false
|
||||||
onTvFocusContent()
|
onTvFocusContent()
|
||||||
}
|
}
|
||||||
|
MainTab.Events -> {
|
||||||
|
navFocused = false
|
||||||
|
onTvFocusContent()
|
||||||
|
hubScope.launch {
|
||||||
|
delay(120)
|
||||||
|
runCatching { eventsContentFocus.requestFocus() }
|
||||||
|
}
|
||||||
|
}
|
||||||
MainTab.Home -> {
|
MainTab.Home -> {
|
||||||
navFocused = false
|
navFocused = false
|
||||||
hubScope.launch {
|
hubScope.launch {
|
||||||
|
|
@ -763,7 +802,11 @@ private fun MainHub(
|
||||||
else -> Unit
|
else -> Unit
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
contentDownFocus = if (tab == MainTab.Home) homeContentFocus else null,
|
contentDownFocus = when (tab) {
|
||||||
|
MainTab.Home -> homeContentFocus
|
||||||
|
MainTab.Events -> eventsContentFocus
|
||||||
|
else -> null
|
||||||
|
},
|
||||||
subItems = subItems,
|
subItems = subItems,
|
||||||
selectedSubId = selectedSubId,
|
selectedSubId = selectedSubId,
|
||||||
focusSelectedToken = topNavFocusToken,
|
focusSelectedToken = topNavFocusToken,
|
||||||
|
|
@ -870,6 +913,12 @@ private fun MainHub(
|
||||||
)
|
)
|
||||||
MainTab.Events -> EventsScreen(
|
MainTab.Events -> EventsScreen(
|
||||||
api = api,
|
api = api,
|
||||||
|
contentFocusRequester = eventsContentFocus,
|
||||||
|
navFocusRequester = navFocus,
|
||||||
|
focusRestoreNonce = eventsReturnNonce,
|
||||||
|
restoreEventId = restoreEventId,
|
||||||
|
pendingFocusRestore = pendingEventsFocusRestore,
|
||||||
|
onFocusRestoreConsumed = onEventsFocusRestoreConsumed,
|
||||||
onOpenEvent = onOpenEvent,
|
onOpenEvent = onOpenEvent,
|
||||||
onPlayEvent = onPlayEvent,
|
onPlayEvent = onPlayEvent,
|
||||||
onContentFocused = { navFocused = false },
|
onContentFocused = { navFocused = false },
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
-- Per-user Live Events toggle + optional provider allowlist (JSON text).
|
||||||
|
ALTER TABLE "viewer_users" ADD COLUMN IF NOT EXISTS "live_events_enabled" BOOLEAN NOT NULL DEFAULT true;
|
||||||
|
ALTER TABLE "viewer_users" ADD COLUMN IF NOT EXISTS "live_events_providers" TEXT;
|
||||||
|
|
@ -76,6 +76,13 @@ model ViewerUser {
|
||||||
appAccess Boolean @default(true) @map("app_access")
|
appAccess Boolean @default(true) @map("app_access")
|
||||||
/** Live TV in de apps (Xtream-lijn op deze gebruiker). */
|
/** Live TV in de apps (Xtream-lijn op deze gebruiker). */
|
||||||
iptvAccess Boolean @default(false) @map("iptv_access")
|
iptvAccess Boolean @default(false) @map("iptv_access")
|
||||||
|
/** Live Events-tab in apps (naast cluster-feed + globale provider-toggles). */
|
||||||
|
liveEventsEnabled Boolean @default(true) @map("live_events_enabled")
|
||||||
|
/**
|
||||||
|
* JSON-array van toegestane provider-ids (lowercase).
|
||||||
|
* null/leeg = alle globaal-ingeschakelde providers.
|
||||||
|
*/
|
||||||
|
liveEventsProviders String? @map("live_events_providers") @db.Text
|
||||||
/** @deprecated Legacy: IPTV via addon-token; voorkeur = iptvLine op deze user. */
|
/** @deprecated Legacy: IPTV via addon-token; voorkeur = iptvLine op deze user. */
|
||||||
iptvAddonTokenId String? @map("iptv_addon_token_id")
|
iptvAddonTokenId String? @map("iptv_addon_token_id")
|
||||||
/** Google-account voor Play Closed Testing; fallback = email */
|
/** Google-account voor Play Closed Testing; fallback = email */
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,11 @@ export type IptvSettingsPublic = {
|
||||||
showAlleZenders: boolean;
|
showAlleZenders: boolean;
|
||||||
/** FAAB (of andere) live-events JSON feed; leeg = default FAAB-URL. */
|
/** FAAB (of andere) live-events JSON feed; leeg = default FAAB-URL. */
|
||||||
liveEventsUrl: string;
|
liveEventsUrl: string;
|
||||||
|
/**
|
||||||
|
* Providers die cluster-breed uit staan.
|
||||||
|
* Nieuwe providers die hier niet in staan blijven aan (default ON).
|
||||||
|
*/
|
||||||
|
liveEventsDisabledProviders: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type StoredJson = Partial<{
|
type StoredJson = Partial<{
|
||||||
|
|
@ -18,6 +23,7 @@ type StoredJson = Partial<{
|
||||||
categoryOrder: unknown;
|
categoryOrder: unknown;
|
||||||
showAlleZenders: unknown;
|
showAlleZenders: unknown;
|
||||||
liveEventsUrl: unknown;
|
liveEventsUrl: unknown;
|
||||||
|
liveEventsDisabledProviders: unknown;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
type CacheEntry = { at: number; settings: IptvSettingsPublic };
|
type CacheEntry = { at: number; settings: IptvSettingsPublic };
|
||||||
|
|
@ -78,6 +84,22 @@ function normalizeIdList(raw: unknown): string[] {
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Provider-ids: trim + lowercase, uniek, gesorteerd. */
|
||||||
|
export function normalizeProviderIdList(raw: unknown): string[] {
|
||||||
|
if (!Array.isArray(raw)) return [];
|
||||||
|
const out: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const item of raw) {
|
||||||
|
const id = String(item ?? "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
if (!id || seen.has(id)) continue;
|
||||||
|
seen.add(id);
|
||||||
|
out.push(id);
|
||||||
|
}
|
||||||
|
return out.sort((a, b) => a.localeCompare(b, "nl"));
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_LIVE_EVENTS_URL =
|
const DEFAULT_LIVE_EVENTS_URL =
|
||||||
"http://epg.faabserver.nl/api/export/live-events?horizon_days=3";
|
"http://epg.faabserver.nl/api/export/live-events?horizon_days=3";
|
||||||
|
|
||||||
|
|
@ -100,6 +122,7 @@ function defaultsFromEnv(config: Config): IptvSettingsPublic {
|
||||||
categoryOrder: [],
|
categoryOrder: [],
|
||||||
showAlleZenders: true,
|
showAlleZenders: true,
|
||||||
liveEventsUrl: DEFAULT_LIVE_EVENTS_URL,
|
liveEventsUrl: DEFAULT_LIVE_EVENTS_URL,
|
||||||
|
liveEventsDisabledProviders: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -118,6 +141,9 @@ function fromStored(stored: StoredJson, config: Config): IptvSettingsPublic {
|
||||||
liveEventsUrl: hasLiveEventsUrl
|
liveEventsUrl: hasLiveEventsUrl
|
||||||
? normalizeSingleUrl(stored.liveEventsUrl, "")
|
? normalizeSingleUrl(stored.liveEventsUrl, "")
|
||||||
: base.liveEventsUrl,
|
: base.liveEventsUrl,
|
||||||
|
liveEventsDisabledProviders: normalizeProviderIdList(
|
||||||
|
stored.liveEventsDisabledProviders
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -149,6 +175,7 @@ export async function upsertIptvSettings(
|
||||||
categoryOrder?: unknown;
|
categoryOrder?: unknown;
|
||||||
showAlleZenders?: unknown;
|
showAlleZenders?: unknown;
|
||||||
liveEventsUrl?: unknown;
|
liveEventsUrl?: unknown;
|
||||||
|
liveEventsDisabledProviders?: unknown;
|
||||||
}
|
}
|
||||||
): Promise<IptvSettingsPublic> {
|
): Promise<IptvSettingsPublic> {
|
||||||
const current = await getIptvSettings(config);
|
const current = await getIptvSettings(config);
|
||||||
|
|
@ -169,6 +196,10 @@ export async function upsertIptvSettings(
|
||||||
body.liveEventsUrl !== undefined
|
body.liveEventsUrl !== undefined
|
||||||
? normalizeSingleUrl(body.liveEventsUrl, "")
|
? normalizeSingleUrl(body.liveEventsUrl, "")
|
||||||
: current.liveEventsUrl,
|
: current.liveEventsUrl,
|
||||||
|
liveEventsDisabledProviders:
|
||||||
|
body.liveEventsDisabledProviders !== undefined
|
||||||
|
? normalizeProviderIdList(body.liveEventsDisabledProviders)
|
||||||
|
: current.liveEventsDisabledProviders,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (body.epgXmlUrls !== undefined) {
|
if (body.epgXmlUrls !== undefined) {
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,20 @@ export function registerSettingsRoutes(
|
||||||
getIptvSettings(config),
|
getIptvSettings(config),
|
||||||
listIptvCategoriesForAdmin(config),
|
listIptvCategoriesForAdmin(config),
|
||||||
]);
|
]);
|
||||||
return { settings, categories };
|
let liveEventProviders: string[] = [];
|
||||||
|
try {
|
||||||
|
const { discoverScheduleEventProviders } = await import("../viewer/schedule-events");
|
||||||
|
liveEventProviders = await discoverScheduleEventProviders(config);
|
||||||
|
} catch {
|
||||||
|
liveEventProviders = [];
|
||||||
|
}
|
||||||
|
// Include known-but-disabled providers so admin can re-enable them
|
||||||
|
const known = new Set([
|
||||||
|
...liveEventProviders,
|
||||||
|
...settings.liveEventsDisabledProviders,
|
||||||
|
]);
|
||||||
|
liveEventProviders = [...known].sort((a, b) => a.localeCompare(b, "nl"));
|
||||||
|
return { settings, categories, liveEventProviders };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.put("/api/v1/admin/settings/iptv", { preHandler: requireAdmin }, async (request) => {
|
app.put("/api/v1/admin/settings/iptv", { preHandler: requireAdmin }, async (request) => {
|
||||||
|
|
@ -96,10 +109,23 @@ export function registerSettingsRoutes(
|
||||||
categoryOrder?: string[];
|
categoryOrder?: string[];
|
||||||
showAlleZenders?: boolean;
|
showAlleZenders?: boolean;
|
||||||
liveEventsUrl?: string;
|
liveEventsUrl?: string;
|
||||||
|
liveEventsDisabledProviders?: string[];
|
||||||
};
|
};
|
||||||
const settings = await upsertIptvSettings(config, body);
|
const settings = await upsertIptvSettings(config, body);
|
||||||
const categories = await listIptvCategoriesForAdmin(config);
|
const categories = await listIptvCategoriesForAdmin(config);
|
||||||
return { settings, categories };
|
let liveEventProviders: string[] = [];
|
||||||
|
try {
|
||||||
|
const { discoverScheduleEventProviders } = await import("../viewer/schedule-events");
|
||||||
|
liveEventProviders = await discoverScheduleEventProviders(config);
|
||||||
|
} catch {
|
||||||
|
liveEventProviders = [];
|
||||||
|
}
|
||||||
|
const known = new Set([
|
||||||
|
...liveEventProviders,
|
||||||
|
...settings.liveEventsDisabledProviders,
|
||||||
|
]);
|
||||||
|
liveEventProviders = [...known].sort((a, b) => a.localeCompare(b, "nl"));
|
||||||
|
return { settings, categories, liveEventProviders };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/v1/admin/settings/iptv/refresh-epg", { preHandler: requireAdmin }, async () => {
|
app.post("/api/v1/admin/settings/iptv/refresh-epg", { preHandler: requireAdmin }, async () => {
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,8 @@ export function registerViewerRoutes(
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
appAccess?: boolean;
|
appAccess?: boolean;
|
||||||
iptvAccess?: boolean;
|
iptvAccess?: boolean;
|
||||||
|
liveEventsEnabled?: boolean;
|
||||||
|
liveEventsProviders?: string[] | null;
|
||||||
iptvAddonTokenId?: string | null;
|
iptvAddonTokenId?: string | null;
|
||||||
password?: string;
|
password?: string;
|
||||||
};
|
};
|
||||||
|
|
@ -507,17 +509,17 @@ export function registerViewerRoutes(
|
||||||
app.get("/api/v1/client/me", async (request) => {
|
app.get("/api/v1/client/me", async (request) => {
|
||||||
const auth = await viewers.authFromBearer(request.headers.authorization);
|
const auth = await viewers.authFromBearer(request.headers.authorization);
|
||||||
const { getViewerIptvStatus } = await import("./iptv-client");
|
const { getViewerIptvStatus } = await import("./iptv-client");
|
||||||
const { eventsFeedEnabled } = await import("./schedule-events");
|
const { resolveViewerLiveEventsAccess } = await import("./schedule-events");
|
||||||
const [iptv, eventsEnabled] = await Promise.all([
|
const [iptv, eventsAccess] = await Promise.all([
|
||||||
getViewerIptvStatus(auth.viewerId, config),
|
getViewerIptvStatus(auth.viewerId, config),
|
||||||
eventsFeedEnabled(config),
|
resolveViewerLiveEventsAccess(auth.viewerId, config),
|
||||||
]);
|
]);
|
||||||
return {
|
return {
|
||||||
viewer: { id: auth.viewerId, email: auth.email, name: auth.name },
|
viewer: { id: auth.viewerId, email: auth.email, name: auth.name },
|
||||||
deviceId: auth.deviceId,
|
deviceId: auth.deviceId,
|
||||||
profile: auth.profile,
|
profile: auth.profile,
|
||||||
iptv: { enabled: iptv.enabled },
|
iptv: { enabled: iptv.enabled },
|
||||||
events: { enabled: eventsEnabled },
|
events: { enabled: eventsAccess.enabled },
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -848,25 +850,28 @@ export function registerViewerRoutes(
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/api/v1/client/events", async (request) => {
|
app.get("/api/v1/client/events", async (request) => {
|
||||||
await viewers.authFromBearer(request.headers.authorization);
|
const auth = await viewers.authFromBearer(request.headers.authorization);
|
||||||
const { listScheduleEvents } = await import("./schedule-events");
|
const { listScheduleEvents, resolveViewerLiveEventsAccess } = await import("./schedule-events");
|
||||||
return listScheduleEvents(config);
|
const access = await resolveViewerLiveEventsAccess(auth.viewerId, config);
|
||||||
|
return listScheduleEvents(config, access);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/api/v1/client/events/:id", async (request) => {
|
app.get("/api/v1/client/events/:id", async (request) => {
|
||||||
await viewers.authFromBearer(request.headers.authorization);
|
const auth = await viewers.authFromBearer(request.headers.authorization);
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
if (!id?.trim()) throw new AppError("INVALID_REQUEST", "Ongeldig event-id", 400);
|
if (!id?.trim()) throw new AppError("INVALID_REQUEST", "Ongeldig event-id", 400);
|
||||||
const { getScheduleEvent } = await import("./schedule-events");
|
const { getScheduleEvent, resolveViewerLiveEventsAccess } = await import("./schedule-events");
|
||||||
return { event: await getScheduleEvent(config, decodeURIComponent(id)) };
|
const access = await resolveViewerLiveEventsAccess(auth.viewerId, config);
|
||||||
|
return { event: await getScheduleEvent(config, decodeURIComponent(id), access) };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/api/v1/client/events/:id/play", async (request) => {
|
app.get("/api/v1/client/events/:id/play", async (request) => {
|
||||||
await viewers.authFromBearer(request.headers.authorization);
|
const auth = await viewers.authFromBearer(request.headers.authorization);
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
if (!id?.trim()) throw new AppError("INVALID_REQUEST", "Ongeldig event-id", 400);
|
if (!id?.trim()) throw new AppError("INVALID_REQUEST", "Ongeldig event-id", 400);
|
||||||
const { getScheduleEventPlay } = await import("./schedule-events");
|
const { getScheduleEventPlay, resolveViewerLiveEventsAccess } = await import("./schedule-events");
|
||||||
return getScheduleEventPlay(config, decodeURIComponent(id));
|
const access = await resolveViewerLiveEventsAccess(auth.viewerId, config);
|
||||||
|
return getScheduleEventPlay(config, decodeURIComponent(id), access);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/api/v1/client/subtitles/search", async (request) => {
|
app.get("/api/v1/client/subtitles/search", async (request) => {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { Config } from "../config";
|
import type { Config } from "../config";
|
||||||
import { AppError } from "../security/errors";
|
import { AppError } from "../security/errors";
|
||||||
import { getIptvSettings } from "../settings/iptv";
|
import { getIptvSettings, normalizeProviderIdList } from "../settings/iptv";
|
||||||
|
import { prisma } from "../database/client";
|
||||||
|
|
||||||
const CACHE_TTL_MS = 60_000;
|
const CACHE_TTL_MS = 60_000;
|
||||||
const FETCH_TIMEOUT_MS = 20_000;
|
const FETCH_TIMEOUT_MS = 20_000;
|
||||||
|
|
@ -32,6 +33,17 @@ export type ScheduleEventPublic = {
|
||||||
audioLanguages: string[];
|
audioLanguages: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Viewer + globale provider-filter voor list/detail/play. */
|
||||||
|
export type LiveEventsAccessFilter = {
|
||||||
|
/** false → 403 op client-endpoints */
|
||||||
|
enabled: boolean;
|
||||||
|
/**
|
||||||
|
* null/empty = alle globaal-ingeschakelde providers.
|
||||||
|
* Anders: subset (lowercase ids).
|
||||||
|
*/
|
||||||
|
allowedProviders: string[] | null;
|
||||||
|
};
|
||||||
|
|
||||||
type RawKid = { kid?: unknown; key?: unknown };
|
type RawKid = { kid?: unknown; key?: unknown };
|
||||||
type RawEvent = {
|
type RawEvent = {
|
||||||
id?: unknown;
|
id?: unknown;
|
||||||
|
|
@ -116,10 +128,18 @@ function pickImages(raw: RawEvent): string[] {
|
||||||
function scoreLandscape(url: string): number {
|
function scoreLandscape(url: string): number {
|
||||||
const u = url.toLowerCase();
|
const u = url.toLowerCase();
|
||||||
let score = 0;
|
let score = 0;
|
||||||
|
// Ziggo Sport FAAB live thumbs (akamaized …/live/…/thumb.jpg?scale=640x360)
|
||||||
|
if (u.includes("thumb")) score += 55;
|
||||||
|
if (u.includes("akamaized.net/live")) score += 50;
|
||||||
|
if (u.includes("scale=")) score += 35;
|
||||||
|
if (u.includes("640x360") || u.includes("scale=640")) score += 30;
|
||||||
if (u.includes("_l.") || u.includes("_l?")) score += 50;
|
if (u.includes("_l.") || u.includes("_l?")) score += 50;
|
||||||
if (u.includes("width=960") && u.includes("height=540")) score += 40;
|
if (u.includes("width=960") && u.includes("height=540")) score += 40;
|
||||||
if (u.includes("width=1280") || u.includes("height=720")) score += 30;
|
if (u.includes("width=1280") || u.includes("height=720")) score += 30;
|
||||||
if (u.includes(".jpg") || u.includes(".jpeg") || u.includes(".webp")) score += 5;
|
if (u.includes(".jpg") || u.includes(".jpeg") || u.includes(".webp")) score += 5;
|
||||||
|
// Channel logo PNGs are not landscape artwork
|
||||||
|
if (u.includes("assets/channels")) score -= 70;
|
||||||
|
if (u.includes("/channels/") && u.includes(".png")) score -= 50;
|
||||||
if (u.includes("logo")) score -= 40;
|
if (u.includes("logo")) score -= 40;
|
||||||
if (u.includes("_c.") || u.includes("_c?")) score -= 20;
|
if (u.includes("_c.") || u.includes("_c?")) score -= 20;
|
||||||
if (u.includes(".png") && u.includes("logo")) score -= 50;
|
if (u.includes(".png") && u.includes("logo")) score -= 50;
|
||||||
|
|
@ -131,7 +151,8 @@ function scorePortrait(url: string): number {
|
||||||
let score = 0;
|
let score = 0;
|
||||||
if (u.includes("_c.") || u.includes("_c?")) score += 50;
|
if (u.includes("_c.") || u.includes("_c?")) score += 50;
|
||||||
if (u.includes("width=199") || u.includes("height=298")) score += 30;
|
if (u.includes("width=199") || u.includes("height=298")) score += 30;
|
||||||
if (u.includes("logo")) score -= 40;
|
if (u.includes("assets/channels") || u.includes("logo")) score -= 40;
|
||||||
|
if (u.includes("thumb") || u.includes("akamaized.net/live")) score -= 20;
|
||||||
if (u.includes("_l.")) score -= 10;
|
if (u.includes("_l.")) score -= 10;
|
||||||
return score;
|
return score;
|
||||||
}
|
}
|
||||||
|
|
@ -139,13 +160,89 @@ function scorePortrait(url: string): number {
|
||||||
function scoreLogo(url: string): number {
|
function scoreLogo(url: string): number {
|
||||||
const u = url.toLowerCase();
|
const u = url.toLowerCase();
|
||||||
let score = 0;
|
let score = 0;
|
||||||
|
if (u.includes("assets/channels")) score += 80;
|
||||||
|
if (u.includes("ziggo_sport")) score += 45;
|
||||||
|
if (u.includes("/channels/") && u.includes(".png")) score += 55;
|
||||||
if (u.includes("logo")) score += 60;
|
if (u.includes("logo")) score += 60;
|
||||||
if (u.includes(".png")) score += 20;
|
if (u.includes(".png")) score += 20;
|
||||||
if (u.includes("cropped")) score += 10;
|
if (u.includes("cropped")) score += 10;
|
||||||
|
// Live thumbs are not logos
|
||||||
|
if (u.includes("thumb") || u.includes("akamaized.net/live") || u.includes("scale=")) score -= 50;
|
||||||
if (u.includes("_l.") || u.includes("_c.")) score -= 30;
|
if (u.includes("_l.") || u.includes("_c.")) score -= 30;
|
||||||
return score;
|
return score;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Viaplay labels like "Stream 10" / "Stream 22 (NEW)" — hide from public meta. */
|
||||||
|
function isStreamLabel(value: string | null | undefined): boolean {
|
||||||
|
const s = str(value);
|
||||||
|
if (!s) return false;
|
||||||
|
return /^stream\s*\d+/i.test(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanChannelLabel(value: string | null | undefined): string | null {
|
||||||
|
const s = str(value);
|
||||||
|
if (!s || isStreamLabel(s)) return null;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeChannelKey(value: string | null | undefined): string {
|
||||||
|
return str(value).toLowerCase().replace(/\s+/g, " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function donorImageScore(e: ScheduleEventPublic): number {
|
||||||
|
let s = 0;
|
||||||
|
if (e.imageLandscape) s += 20;
|
||||||
|
if (e.images.some((u) => /thumb|akamaized\.net\/live|scale=/i.test(u))) s += 30;
|
||||||
|
if (e.logoUrl) s += 5;
|
||||||
|
if (e.images.length) s += Math.min(e.images.length, 5);
|
||||||
|
if (e.provider === "ziggosport") s += 8;
|
||||||
|
if (e.playable) s += 3;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Odido (and others) often publish the same channel without images while
|
||||||
|
* ziggosport/FAAB has thumbs for that channel. Fill gaps by channel/network key.
|
||||||
|
*/
|
||||||
|
function fillMissingImages(events: ScheduleEventPublic[]): ScheduleEventPublic[] {
|
||||||
|
const donors = new Map<string, ScheduleEventPublic>();
|
||||||
|
for (const e of events) {
|
||||||
|
if (!e.imageLandscape && !e.logoUrl && e.images.length === 0) continue;
|
||||||
|
const keys = [e.channel, e.network]
|
||||||
|
.map(normalizeChannelKey)
|
||||||
|
.filter((k) => k.length > 0);
|
||||||
|
for (const key of keys) {
|
||||||
|
const existing = donors.get(key);
|
||||||
|
if (!existing || donorImageScore(e) > donorImageScore(existing)) {
|
||||||
|
donors.set(key, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return events.map((e) => {
|
||||||
|
if (e.imageLandscape || e.images.length > 0) return e;
|
||||||
|
const keys = [e.channel, e.network]
|
||||||
|
.map(normalizeChannelKey)
|
||||||
|
.filter((k) => k.length > 0);
|
||||||
|
let donor: ScheduleEventPublic | undefined;
|
||||||
|
for (const key of keys) {
|
||||||
|
const hit = donors.get(key);
|
||||||
|
if (hit && hit.id !== e.id) {
|
||||||
|
donor = hit;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!donor) return e;
|
||||||
|
return {
|
||||||
|
...e,
|
||||||
|
imageLandscape: e.imageLandscape || donor.imageLandscape,
|
||||||
|
imagePortrait: e.imagePortrait || donor.imagePortrait,
|
||||||
|
logoUrl: e.logoUrl || donor.logoUrl,
|
||||||
|
images: e.images.length ? e.images : [...donor.images],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function bestByScore(urls: string[], scorer: (u: string) => number): string | null {
|
function bestByScore(urls: string[], scorer: (u: string) => number): string | null {
|
||||||
let best: string | null = null;
|
let best: string | null = null;
|
||||||
let bestScore = -Infinity;
|
let bestScore = -Infinity;
|
||||||
|
|
@ -231,6 +328,9 @@ function toPublic(raw: RawEvent, nowMs: number): ScheduleEventPublic | null {
|
||||||
const isLiveFlag = raw.is_live === true;
|
const isLiveFlag = raw.is_live === true;
|
||||||
const phase = phaseFor(start, stop, isLiveFlag, nowMs);
|
const phase = phaseFor(start, stop, isLiveFlag, nowMs);
|
||||||
|
|
||||||
|
const channel = cleanChannelLabel(str(raw.epg?.channel) || str(meta.zender));
|
||||||
|
const network = cleanChannelLabel(str(raw.epg?.network));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
|
|
@ -238,12 +338,12 @@ function toPublic(raw: RawEvent, nowMs: number): ScheduleEventPublic | null {
|
||||||
str(raw.description) ||
|
str(raw.description) ||
|
||||||
str(raw.epg?.description) ||
|
str(raw.epg?.description) ||
|
||||||
null,
|
null,
|
||||||
provider: str(raw.provider) || "unknown",
|
provider: (str(raw.provider) || "unknown").toLowerCase(),
|
||||||
category: str(raw.category) || str(raw.epg?.category) || null,
|
category: str(raw.category) || str(raw.epg?.category) || null,
|
||||||
eventType: str(meta.type) || null,
|
eventType: str(meta.type) || null,
|
||||||
subtitle: str(meta["originele titel"]) || str(raw.epg?.subtitle) || null,
|
subtitle: str(meta["originele titel"]) || str(raw.epg?.subtitle) || null,
|
||||||
channel: str(raw.epg?.channel) || str(meta.zender) || null,
|
channel,
|
||||||
network: str(raw.epg?.network) || null,
|
network,
|
||||||
isLive: phase === "live",
|
isLive: phase === "live",
|
||||||
phase,
|
phase,
|
||||||
playable: isPlayable(raw) && phase === "live",
|
playable: isPlayable(raw) && phase === "live",
|
||||||
|
|
@ -274,6 +374,78 @@ export async function eventsFeedEnabled(config: Config): Promise<boolean> {
|
||||||
return !!settings.liveEventsUrl?.trim();
|
return !!settings.liveEventsUrl?.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Parse JSON allowlist; null/empty → null (= alle globaal-enabled). */
|
||||||
|
export function parseLiveEventsProvidersJson(raw: string | null | undefined): string[] | null {
|
||||||
|
if (!raw?.trim()) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
|
const list = normalizeProviderIdList(parsed);
|
||||||
|
return list.length ? list : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeLiveEventsProviders(providers: string[] | null | undefined): string | null {
|
||||||
|
if (providers == null) return null;
|
||||||
|
const list = normalizeProviderIdList(providers);
|
||||||
|
return list.length ? JSON.stringify(list) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveViewerLiveEventsAccess(
|
||||||
|
viewerId: string,
|
||||||
|
config: Config
|
||||||
|
): Promise<LiveEventsAccessFilter> {
|
||||||
|
const feedOn = await eventsFeedEnabled(config);
|
||||||
|
const viewer = await prisma.viewerUser.findUnique({
|
||||||
|
where: { id: viewerId },
|
||||||
|
select: {
|
||||||
|
enabled: true,
|
||||||
|
appAccess: true,
|
||||||
|
liveEventsEnabled: true,
|
||||||
|
liveEventsProviders: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!viewer || !viewer.enabled || !viewer.appAccess || !viewer.liveEventsEnabled || !feedOn) {
|
||||||
|
return { enabled: false, allowedProviders: null };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
allowedProviders: parseLiveEventsProvidersJson(viewer.liveEventsProviders),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function globalDisabledProviders(config: Config): Promise<Set<string>> {
|
||||||
|
const settings = await getIptvSettings(config);
|
||||||
|
return new Set(settings.liveEventsDisabledProviders.map((p) => p.toLowerCase()));
|
||||||
|
}
|
||||||
|
|
||||||
|
function providerAllowed(
|
||||||
|
provider: string,
|
||||||
|
disabledGlobal: Set<string>,
|
||||||
|
userAllow: string[] | null
|
||||||
|
): boolean {
|
||||||
|
const p = provider.trim().toLowerCase() || "unknown";
|
||||||
|
if (disabledGlobal.has(p)) return false;
|
||||||
|
if (!userAllow || userAllow.length === 0) return true;
|
||||||
|
return userAllow.includes(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertAccessEnabled(access?: LiveEventsAccessFilter | null) {
|
||||||
|
if (access && !access.enabled) {
|
||||||
|
throw new AppError("FORBIDDEN", "Live Events is uitgeschakeld voor dit account", 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterByAccess(
|
||||||
|
events: ScheduleEventPublic[],
|
||||||
|
disabledGlobal: Set<string>,
|
||||||
|
access?: LiveEventsAccessFilter | null
|
||||||
|
): ScheduleEventPublic[] {
|
||||||
|
const userAllow = access?.allowedProviders ?? null;
|
||||||
|
return events.filter((e) => providerAllowed(e.provider, disabledGlobal, userAllow));
|
||||||
|
}
|
||||||
|
|
||||||
async function loadRawEvents(config: Config): Promise<CacheEntry> {
|
async function loadRawEvents(config: Config): Promise<CacheEntry> {
|
||||||
const url = await resolveLiveEventsUrl(config);
|
const url = await resolveLiveEventsUrl(config);
|
||||||
if (cache && cache.url === url && Date.now() - cache.at < CACHE_TTL_MS) {
|
if (cache && cache.url === url && Date.now() - cache.at < CACHE_TTL_MS) {
|
||||||
|
|
@ -323,14 +495,36 @@ export function invalidateScheduleEventsCache() {
|
||||||
cache = null;
|
cache = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listScheduleEvents(config: Config): Promise<{
|
/** Distinct providers uit de ruwe feed (incl. ended) — voor admin toggles. */
|
||||||
|
export async function discoverScheduleEventProviders(config: Config): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const raw = await loadRawEvents(config);
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const e of raw.events) {
|
||||||
|
const p = (str(e.provider) || "unknown").toLowerCase();
|
||||||
|
if (seen.has(p)) continue;
|
||||||
|
seen.add(p);
|
||||||
|
out.push(p);
|
||||||
|
}
|
||||||
|
return out.sort((a, b) => a.localeCompare(b, "nl"));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listScheduleEvents(
|
||||||
|
config: Config,
|
||||||
|
access?: LiveEventsAccessFilter | null
|
||||||
|
): Promise<{
|
||||||
generatedAt: string | null;
|
generatedAt: string | null;
|
||||||
events: ScheduleEventPublic[];
|
events: ScheduleEventPublic[];
|
||||||
providers: string[];
|
providers: string[];
|
||||||
}> {
|
}> {
|
||||||
|
assertAccessEnabled(access);
|
||||||
const raw = await loadRawEvents(config);
|
const raw = await loadRawEvents(config);
|
||||||
const nowMs = Date.now();
|
const nowMs = Date.now();
|
||||||
const events = raw.events
|
const mapped = raw.events
|
||||||
.map((e) => toPublic(e, nowMs))
|
.map((e) => toPublic(e, nowMs))
|
||||||
.filter((e): e is ScheduleEventPublic => e != null)
|
.filter((e): e is ScheduleEventPublic => e != null)
|
||||||
.filter((e) => e.phase !== "ended")
|
.filter((e) => e.phase !== "ended")
|
||||||
|
|
@ -339,8 +533,10 @@ export async function listScheduleEvents(config: Config): Promise<{
|
||||||
if (b.phase === "live" && a.phase !== "live") return 1;
|
if (b.phase === "live" && a.phase !== "live") return 1;
|
||||||
return Date.parse(a.start) - Date.parse(b.start);
|
return Date.parse(a.start) - Date.parse(b.start);
|
||||||
});
|
});
|
||||||
|
const disabledGlobal = await globalDisabledProviders(config);
|
||||||
|
const events = filterByAccess(fillMissingImages(mapped), disabledGlobal, access);
|
||||||
|
|
||||||
const providers = [...new Set(events.map((e) => e.provider))].sort((a, b) =>
|
const providers = [...new Set(events.map((e) => e.provider.toLowerCase()))].sort((a, b) =>
|
||||||
a.localeCompare(b, "nl")
|
a.localeCompare(b, "nl")
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -353,24 +549,35 @@ export async function listScheduleEvents(config: Config): Promise<{
|
||||||
|
|
||||||
export async function getScheduleEvent(
|
export async function getScheduleEvent(
|
||||||
config: Config,
|
config: Config,
|
||||||
eventId: string
|
eventId: string,
|
||||||
|
access?: LiveEventsAccessFilter | null
|
||||||
): Promise<ScheduleEventPublic> {
|
): Promise<ScheduleEventPublic> {
|
||||||
|
assertAccessEnabled(access);
|
||||||
const id = str(eventId);
|
const id = str(eventId);
|
||||||
const listed = await listScheduleEvents(config);
|
const listed = await listScheduleEvents(config, access);
|
||||||
const hit = listed.events.find((e) => e.id === id);
|
const hit = listed.events.find((e) => e.id === id);
|
||||||
if (hit) return hit;
|
if (hit) return hit;
|
||||||
|
|
||||||
// Ended events still resolvable for detail (no play)
|
// Ended events still resolvable for detail (no play) if provider allowed
|
||||||
const raw = await loadRawEvents(config);
|
const raw = await loadRawEvents(config);
|
||||||
const nowMs = Date.now();
|
const nowMs = Date.now();
|
||||||
const found = raw.events.find((e) => str(e.id) === id);
|
const found = raw.events.find((e) => str(e.id) === id);
|
||||||
if (!found) throw new AppError("NOT_FOUND", "Event niet gevonden", 404);
|
if (!found) throw new AppError("NOT_FOUND", "Event niet gevonden", 404);
|
||||||
const pub = toPublic(found, nowMs);
|
const pub = toPublic(found, nowMs);
|
||||||
if (!pub) throw new AppError("NOT_FOUND", "Event niet gevonden", 404);
|
if (!pub) throw new AppError("NOT_FOUND", "Event niet gevonden", 404);
|
||||||
|
const disabledGlobal = await globalDisabledProviders(config);
|
||||||
|
if (!providerAllowed(pub.provider, disabledGlobal, access?.allowedProviders ?? null)) {
|
||||||
|
throw new AppError("NOT_FOUND", "Event niet gevonden", 404);
|
||||||
|
}
|
||||||
return pub;
|
return pub;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getScheduleEventPlay(config: Config, eventId: string) {
|
export async function getScheduleEventPlay(
|
||||||
|
config: Config,
|
||||||
|
eventId: string,
|
||||||
|
access?: LiveEventsAccessFilter | null
|
||||||
|
) {
|
||||||
|
assertAccessEnabled(access);
|
||||||
const id = str(eventId);
|
const id = str(eventId);
|
||||||
const raw = await loadRawEvents(config);
|
const raw = await loadRawEvents(config);
|
||||||
const found = raw.events.find((e) => str(e.id) === id);
|
const found = raw.events.find((e) => str(e.id) === id);
|
||||||
|
|
@ -379,6 +586,12 @@ export async function getScheduleEventPlay(config: Config, eventId: string) {
|
||||||
const nowMs = Date.now();
|
const nowMs = Date.now();
|
||||||
const pub = toPublic(found, nowMs);
|
const pub = toPublic(found, nowMs);
|
||||||
if (!pub) throw new AppError("NOT_FOUND", "Event niet gevonden", 404);
|
if (!pub) throw new AppError("NOT_FOUND", "Event niet gevonden", 404);
|
||||||
|
|
||||||
|
const disabledGlobal = await globalDisabledProviders(config);
|
||||||
|
if (!providerAllowed(pub.provider, disabledGlobal, access?.allowedProviders ?? null)) {
|
||||||
|
throw new AppError("NOT_FOUND", "Event niet gevonden", 404);
|
||||||
|
}
|
||||||
|
|
||||||
if (pub.phase === "ended") {
|
if (pub.phase === "ended") {
|
||||||
throw new AppError("GONE", "Dit event is afgelopen", 410);
|
throw new AppError("GONE", "Dit event is afgelopen", 410);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,11 @@ import {
|
||||||
invalidateHomeForProfile,
|
invalidateHomeForProfile,
|
||||||
} from "./home-cache";
|
} from "./home-cache";
|
||||||
import { compactListImages } from "./image-urls";
|
import { compactListImages } from "./image-urls";
|
||||||
|
import {
|
||||||
|
parseLiveEventsProvidersJson,
|
||||||
|
serializeLiveEventsProviders,
|
||||||
|
} from "./schedule-events";
|
||||||
|
import { normalizeProviderIdList } from "../settings/iptv";
|
||||||
|
|
||||||
const CODE_TTL_MS = 10 * 60 * 1000;
|
const CODE_TTL_MS = 10 * 60 * 1000;
|
||||||
const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||||
|
|
@ -186,6 +191,8 @@ export class ViewerService {
|
||||||
enabled: v.enabled,
|
enabled: v.enabled,
|
||||||
appAccess: v.appAccess,
|
appAccess: v.appAccess,
|
||||||
iptvAccess: v.iptvAccess,
|
iptvAccess: v.iptvAccess,
|
||||||
|
liveEventsEnabled: v.liveEventsEnabled,
|
||||||
|
liveEventsProviders: parseLiveEventsProvidersJson(v.liveEventsProviders),
|
||||||
iptvAddonTokenId: v.iptvAddonTokenId,
|
iptvAddonTokenId: v.iptvAddonTokenId,
|
||||||
liveListIds: v.liveChannelLists.map((r) => r.listId),
|
liveListIds: v.liveChannelLists.map((r) => r.listId),
|
||||||
liveLists: v.liveChannelLists.map((r) => ({
|
liveLists: v.liveChannelLists.map((r) => ({
|
||||||
|
|
@ -249,6 +256,8 @@ export class ViewerService {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
appAccess?: boolean;
|
appAccess?: boolean;
|
||||||
iptvAccess?: boolean;
|
iptvAccess?: boolean;
|
||||||
|
liveEventsEnabled?: boolean;
|
||||||
|
liveEventsProviders?: string[] | null;
|
||||||
iptvAddonTokenId?: string | null;
|
iptvAddonTokenId?: string | null;
|
||||||
password?: string;
|
password?: string;
|
||||||
}
|
}
|
||||||
|
|
@ -265,6 +274,8 @@ export class ViewerService {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
appAccess?: boolean;
|
appAccess?: boolean;
|
||||||
iptvAccess?: boolean;
|
iptvAccess?: boolean;
|
||||||
|
liveEventsEnabled?: boolean;
|
||||||
|
liveEventsProviders?: string | null;
|
||||||
iptvAddonTokenId?: string | null;
|
iptvAddonTokenId?: string | null;
|
||||||
passwordHash?: string;
|
passwordHash?: string;
|
||||||
} = {};
|
} = {};
|
||||||
|
|
@ -272,6 +283,15 @@ export class ViewerService {
|
||||||
if (data.enabled !== undefined) patch.enabled = data.enabled;
|
if (data.enabled !== undefined) patch.enabled = data.enabled;
|
||||||
if (data.appAccess !== undefined) patch.appAccess = data.appAccess;
|
if (data.appAccess !== undefined) patch.appAccess = data.appAccess;
|
||||||
if (data.iptvAccess !== undefined) patch.iptvAccess = data.iptvAccess;
|
if (data.iptvAccess !== undefined) patch.iptvAccess = data.iptvAccess;
|
||||||
|
if (data.liveEventsEnabled !== undefined) patch.liveEventsEnabled = data.liveEventsEnabled;
|
||||||
|
if (data.liveEventsProviders !== undefined) {
|
||||||
|
if (data.liveEventsProviders === null) {
|
||||||
|
patch.liveEventsProviders = null;
|
||||||
|
} else {
|
||||||
|
const list = normalizeProviderIdList(data.liveEventsProviders);
|
||||||
|
patch.liveEventsProviders = serializeLiveEventsProviders(list);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (data.iptvAddonTokenId !== undefined) {
|
if (data.iptvAddonTokenId !== undefined) {
|
||||||
patch.iptvAddonTokenId = data.iptvAddonTokenId?.trim() || null;
|
patch.iptvAddonTokenId = data.iptvAddonTokenId?.trim() || null;
|
||||||
}
|
}
|
||||||
|
|
@ -302,6 +322,8 @@ export class ViewerService {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
appAccess: true,
|
appAccess: true,
|
||||||
iptvAccess: true,
|
iptvAccess: true,
|
||||||
|
liveEventsEnabled: true,
|
||||||
|
liveEventsProviders: true,
|
||||||
iptvAddonTokenId: true,
|
iptvAddonTokenId: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
},
|
},
|
||||||
|
|
@ -315,7 +337,10 @@ export class ViewerService {
|
||||||
if (Object.keys(patch).length > 0) {
|
if (Object.keys(patch).length > 0) {
|
||||||
notifyViewersChanged("viewer.updated", { viewerId: updated.id });
|
notifyViewersChanged("viewer.updated", { viewerId: updated.id });
|
||||||
}
|
}
|
||||||
return updated;
|
return {
|
||||||
|
...updated,
|
||||||
|
liveEventsProviders: parseLiveEventsProvidersJson(updated.liveEventsProviders),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async revokeDevice(deviceId: string) {
|
async revokeDevice(deviceId: string) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue