Add Dutch TV logo picker from tv-logo/tv-logos for live channels.

Admin can search NL logos like EPG and store the jsDelivr CDN URL.
This commit is contained in:
Jos Vooges | STH 2026-09-18 20:00:20 +02:00
parent f6c48ff488
commit c97374c9cb
3 changed files with 201 additions and 0 deletions

View file

@ -38,6 +38,7 @@ type LiveListDetail = {
};
type EpgOption = { id: string; name: string };
type LogoOption = { id: string; name: string; url: string };
type ChannelForm = {
name: string;
@ -80,6 +81,8 @@ export default function LiveListsPage() {
const [detail, setDetail] = useState<LiveListDetail | null>(null);
const [epgOptions, setEpgOptions] = useState<EpgOption[]>([]);
const [epgFilter, setEpgFilter] = useState("");
const [logoOptions, setLogoOptions] = useState<LogoOption[]>([]);
const [logoFilter, setLogoFilter] = useState("");
const [newListName, setNewListName] = useState("");
const [listName, setListName] = useState("");
const [listEnabled, setListEnabled] = useState(true);
@ -111,6 +114,10 @@ export default function LiveListsPage() {
.then((r) => r.json())
.then((d) => setEpgOptions(d.channels ?? []))
.catch(() => undefined);
fetch("/api/v1/admin/live-lists/logo-options", { credentials: "include" })
.then((r) => r.json())
.then((d) => setLogoOptions(d.logos ?? []))
.catch(() => undefined);
}, [loadLists]);
useEffect(() => {
@ -136,6 +143,24 @@ export default function LiveListsPage() {
.slice(0, 200);
}, [epgOptions, epgFilter]);
const filteredLogos = useMemo(() => {
const q = logoFilter.trim().toLowerCase();
if (!q) return logoOptions.slice(0, 80);
return logoOptions
.filter(
(o) =>
o.name.toLowerCase().includes(q) ||
o.id.toLowerCase().includes(q) ||
o.url.toLowerCase().includes(q)
)
.slice(0, 80);
}, [logoOptions, logoFilter]);
function pickLogo(logo: LogoOption) {
setChannelForm((f) => ({ ...f, logoUrl: logo.url }));
setLogoFilter(logo.name);
}
async function createList(e: FormEvent) {
e.preventDefault();
const name = newListName.trim();
@ -211,11 +236,16 @@ export default function LiveListsPage() {
function startCreateChannel() {
setEditingChannelId("new");
setChannelForm(emptyChannelForm());
setLogoFilter("");
setEpgFilter("");
}
function startEditChannel(c: LiveChannelRow) {
setEditingChannelId(c.id);
setChannelForm(formFromChannel(c));
const match = logoOptions.find((o) => o.url === c.logoUrl);
setLogoFilter(match?.name ?? "");
setEpgFilter("");
}
async function saveChannel(e: FormEvent) {
@ -446,14 +476,104 @@ export default function LiveListsPage() {
autoComplete="off"
/>
</label>
<label className="field">
Logo zoeken (tv-logos NL)
<input
list="channel-logo-options"
value={logoFilter}
onChange={(e) => {
const v = e.target.value;
setLogoFilter(v);
const hit = logoOptions.find(
(o) =>
o.name === v ||
o.id === v ||
o.url === v ||
o.name.toLowerCase() === v.trim().toLowerCase()
);
if (hit) setChannelForm((f) => ({ ...f, logoUrl: hit.url }));
}}
placeholder="Typ zendernaam, bijv. NPO 1 of RTL 4"
disabled={busy}
/>
</label>
<datalist id="channel-logo-options">
{filteredLogos.map((o) => (
<option key={o.id} value={o.name}>
{o.id}
</option>
))}
</datalist>
{filteredLogos.length > 0 && (
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: 8,
marginBottom: 12,
maxHeight: 140,
overflow: "auto",
}}
>
{filteredLogos.slice(0, 24).map((o) => (
<button
key={o.id}
type="button"
onClick={() => pickLogo(o)}
title={o.name}
style={{
display: "flex",
alignItems: "center",
gap: 6,
padding: "4px 8px",
background:
channelForm.logoUrl === o.url
? "rgba(229,9,20,0.25)"
: "rgba(255,255,255,0.06)",
border:
channelForm.logoUrl === o.url
? "1px solid #e50914"
: "1px solid transparent",
borderRadius: 6,
cursor: "pointer",
color: "inherit",
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={o.url}
alt=""
width={28}
height={28}
style={{ objectFit: "contain", background: "#111" }}
/>
<span style={{ fontSize: "0.8rem" }}>{o.name}</span>
</button>
))}
</div>
)}
<label className="field">
Logo-URL (optioneel)
<input
value={channelForm.logoUrl}
onChange={(e) => setChannelForm((f) => ({ ...f, logoUrl: e.target.value }))}
placeholder="Automatisch na logo-keuze, of plak eigen URL"
disabled={busy}
/>
</label>
{channelForm.logoUrl.trim() && (
<p className="muted field-hint" style={{ display: "flex", alignItems: "center", gap: 10 }}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={channelForm.logoUrl}
alt="Logo preview"
width={48}
height={48}
style={{ objectFit: "contain", background: "#111", borderRadius: 4 }}
/>
Preview
</p>
)}
<label className="field">
EPG-kanaal
<input

View file

@ -1567,6 +1567,11 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
return { channels: await listEpgChannelOptions(config) };
});
app.get("/api/v1/admin/live-lists/logo-options", { preHandler: requireAdmin }, async () => {
const { listChannelLogoOptions } = await import("../viewer/live-channels");
return { logos: await listChannelLogoOptions() };
});
app.get("/api/v1/admin/live-lists/:id", { preHandler: requireAdmin }, async (request) => {
const { id } = request.params as { id: string };
const { getLiveChannelListAdmin } = await import("../viewer/live-channels");

View file

@ -549,6 +549,82 @@ export async function listEpgChannelOptions(config: Config) {
.sort((a, b) => a.name.localeCompare(b.name, "nl"));
}
const TV_LOGOS_GH =
"https://api.github.com/repos/tv-logo/tv-logos/contents/countries/netherlands";
const TV_LOGOS_CDN =
"https://cdn.jsdelivr.net/gh/tv-logo/tv-logos@main/countries/netherlands";
type LogoOption = { id: string; name: string; url: string };
let logoOptionsCache: { at: number; logos: LogoOption[] } | null = null;
const LOGO_CACHE_MS = 6 * 60 * 60_000;
function filenameToLogoLabel(filename: string): string {
let s = filename.replace(/\.png$/i, "");
s = s.replace(/-nl$/i, "");
s = s.replace(/-/g, " ").trim();
return s
.split(/\s+/)
.map((w) => {
if (/^\d/.test(w)) return w.toUpperCase();
if (w.length <= 3 && /^[a-z0-9]+$/i.test(w)) return w.toUpperCase();
return w.charAt(0).toUpperCase() + w.slice(1);
})
.join(" ");
}
/**
* Admin logo-picker: PNG’s uit tv-logo/tv-logos (Netherlands), via jsDelivr CDN.
* @see https://github.com/tv-logo/tv-logos/tree/main/countries/netherlands
*/
export async function listChannelLogoOptions(): Promise<LogoOption[]> {
if (logoOptionsCache && Date.now() - logoOptionsCache.at < LOGO_CACHE_MS) {
return logoOptionsCache.logos;
}
const res = await fetch(TV_LOGOS_GH, {
headers: {
Accept: "application/vnd.github+json",
"User-Agent": "vonas-media-cluster-admin",
},
});
if (!res.ok) {
if (logoOptionsCache) return logoOptionsCache.logos;
throw new AppError(
"UPSTREAM_ERROR",
`Logo-lijst laden mislukt (${res.status})`,
502
);
}
const rows = (await res.json()) as Array<{
name?: string;
type?: string;
download_url?: string | null;
}>;
const logos = rows
.filter(
(r) =>
r.type === "file" &&
typeof r.name === "string" &&
/\.png$/i.test(r.name) &&
!r.name.startsWith("0_")
)
.map((r) => {
const filename = r.name!;
return {
id: filename,
name: filenameToLogoLabel(filename),
url: `${TV_LOGOS_CDN}/${encodeURIComponent(filename)}`,
};
})
.sort((a, b) => a.name.localeCompare(b.name, "nl"));
logoOptionsCache = { at: Date.now(), logos };
return logos;
}
async function loadXmltvGuidesForEpg(
config: Config
): Promise<Map<string, XmltvChannelGuide>> {