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 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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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>> {
|
||||
|
|
|
|||
Loading…
Reference in a new issue