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:
parent
f6c48ff488
commit
c97374c9cb
3 changed files with 201 additions and 0 deletions
|
|
@ -38,6 +38,7 @@ type LiveListDetail = {
|
||||||
};
|
};
|
||||||
|
|
||||||
type EpgOption = { id: string; name: string };
|
type EpgOption = { id: string; name: string };
|
||||||
|
type LogoOption = { id: string; name: string; url: string };
|
||||||
|
|
||||||
type ChannelForm = {
|
type ChannelForm = {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -80,6 +81,8 @@ export default function LiveListsPage() {
|
||||||
const [detail, setDetail] = useState<LiveListDetail | null>(null);
|
const [detail, setDetail] = useState<LiveListDetail | null>(null);
|
||||||
const [epgOptions, setEpgOptions] = useState<EpgOption[]>([]);
|
const [epgOptions, setEpgOptions] = useState<EpgOption[]>([]);
|
||||||
const [epgFilter, setEpgFilter] = useState("");
|
const [epgFilter, setEpgFilter] = useState("");
|
||||||
|
const [logoOptions, setLogoOptions] = useState<LogoOption[]>([]);
|
||||||
|
const [logoFilter, setLogoFilter] = useState("");
|
||||||
const [newListName, setNewListName] = useState("");
|
const [newListName, setNewListName] = useState("");
|
||||||
const [listName, setListName] = useState("");
|
const [listName, setListName] = useState("");
|
||||||
const [listEnabled, setListEnabled] = useState(true);
|
const [listEnabled, setListEnabled] = useState(true);
|
||||||
|
|
@ -111,6 +114,10 @@ export default function LiveListsPage() {
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((d) => setEpgOptions(d.channels ?? []))
|
.then((d) => setEpgOptions(d.channels ?? []))
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
|
fetch("/api/v1/admin/live-lists/logo-options", { credentials: "include" })
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((d) => setLogoOptions(d.logos ?? []))
|
||||||
|
.catch(() => undefined);
|
||||||
}, [loadLists]);
|
}, [loadLists]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -136,6 +143,24 @@ export default function LiveListsPage() {
|
||||||
.slice(0, 200);
|
.slice(0, 200);
|
||||||
}, [epgOptions, epgFilter]);
|
}, [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) {
|
async function createList(e: FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const name = newListName.trim();
|
const name = newListName.trim();
|
||||||
|
|
@ -211,11 +236,16 @@ export default function LiveListsPage() {
|
||||||
function startCreateChannel() {
|
function startCreateChannel() {
|
||||||
setEditingChannelId("new");
|
setEditingChannelId("new");
|
||||||
setChannelForm(emptyChannelForm());
|
setChannelForm(emptyChannelForm());
|
||||||
|
setLogoFilter("");
|
||||||
|
setEpgFilter("");
|
||||||
}
|
}
|
||||||
|
|
||||||
function startEditChannel(c: LiveChannelRow) {
|
function startEditChannel(c: LiveChannelRow) {
|
||||||
setEditingChannelId(c.id);
|
setEditingChannelId(c.id);
|
||||||
setChannelForm(formFromChannel(c));
|
setChannelForm(formFromChannel(c));
|
||||||
|
const match = logoOptions.find((o) => o.url === c.logoUrl);
|
||||||
|
setLogoFilter(match?.name ?? "");
|
||||||
|
setEpgFilter("");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveChannel(e: FormEvent) {
|
async function saveChannel(e: FormEvent) {
|
||||||
|
|
@ -446,14 +476,104 @@ export default function LiveListsPage() {
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
</label>
|
</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">
|
<label className="field">
|
||||||
Logo-URL (optioneel)
|
Logo-URL (optioneel)
|
||||||
<input
|
<input
|
||||||
value={channelForm.logoUrl}
|
value={channelForm.logoUrl}
|
||||||
onChange={(e) => setChannelForm((f) => ({ ...f, logoUrl: e.target.value }))}
|
onChange={(e) => setChannelForm((f) => ({ ...f, logoUrl: e.target.value }))}
|
||||||
|
placeholder="Automatisch na logo-keuze, of plak eigen URL"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
/>
|
/>
|
||||||
</label>
|
</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">
|
<label className="field">
|
||||||
EPG-kanaal
|
EPG-kanaal
|
||||||
<input
|
<input
|
||||||
|
|
|
||||||
|
|
@ -1567,6 +1567,11 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
|
||||||
return { channels: await listEpgChannelOptions(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) => {
|
app.get("/api/v1/admin/live-lists/:id", { preHandler: requireAdmin }, async (request) => {
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const { getLiveChannelListAdmin } = await import("../viewer/live-channels");
|
const { getLiveChannelListAdmin } = await import("../viewer/live-channels");
|
||||||
|
|
|
||||||
|
|
@ -549,6 +549,82 @@ export async function listEpgChannelOptions(config: Config) {
|
||||||
.sort((a, b) => a.name.localeCompare(b.name, "nl"));
|
.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(
|
async function loadXmltvGuidesForEpg(
|
||||||
config: Config
|
config: Config
|
||||||
): Promise<Map<string, XmltvChannelGuide>> {
|
): Promise<Map<string, XmltvChannelGuide>> {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue