Pakiety tematyczne
This commit is contained in:
347
src/pages/api/jambox/import-jambox-tematyczne-channels.js
Normal file
347
src/pages/api/jambox/import-jambox-tematyczne-channels.js
Normal file
@@ -0,0 +1,347 @@
|
||||
import path from "node:path";
|
||||
import fs from "node:fs/promises";
|
||||
import yaml from "js-yaml";
|
||||
import { XMLParser } from "fast-xml-parser";
|
||||
import Database from "better-sqlite3";
|
||||
|
||||
const CHANNELS_URL = "https://www.jambox.pl/xml/listakanalow.xml";
|
||||
|
||||
const DB_PATH =
|
||||
process.env.FUZ_DB_PATH ||
|
||||
path.join(process.cwd(), "src", "data", "ServicesRange.db");
|
||||
|
||||
function isAuthorized(request) {
|
||||
const expected = import.meta.env.JAMBOX_ADMIN_TOKEN;
|
||||
if (!expected) return false;
|
||||
const token = request.headers.get("x-admin-token");
|
||||
return token === expected;
|
||||
}
|
||||
|
||||
function getDb() {
|
||||
const db = new Database(DB_PATH);
|
||||
db.pragma("journal_mode = WAL");
|
||||
return db;
|
||||
}
|
||||
|
||||
async function fetchXml(url) {
|
||||
const res = await fetch(url, {
|
||||
headers: { accept: "application/xml,text/xml,*/*" },
|
||||
});
|
||||
if (!res.ok) throw new Error(`XML HTTP ${res.status} for ${url}`);
|
||||
return await res.text();
|
||||
}
|
||||
|
||||
function parseNodes(xmlText) {
|
||||
const parser = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
attributeNamePrefix: "@_",
|
||||
trimValues: true,
|
||||
});
|
||||
const json = parser.parse(xmlText);
|
||||
const nodes = json?.xml?.node ?? json?.node ?? [];
|
||||
return Array.isArray(nodes) ? nodes : [nodes];
|
||||
}
|
||||
|
||||
function toInt(v) {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function parseCsvIds(v) {
|
||||
const s = String(v ?? "").trim();
|
||||
if (!s) return [];
|
||||
return s
|
||||
.split(",")
|
||||
.map((x) => toInt(x.trim()))
|
||||
.filter((n) => n != null);
|
||||
}
|
||||
|
||||
function decodeEntities(input) {
|
||||
if (!input) return "";
|
||||
let s = String(input)
|
||||
.replace(/ /g, " ")
|
||||
.replace(/–/g, "–")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
s = s.replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(Number(d)));
|
||||
s = s.replace(/&#x([0-9a-fA-F]+);/g, (_, h) =>
|
||||
String.fromCodePoint(parseInt(h, 16)),
|
||||
);
|
||||
return s;
|
||||
}
|
||||
|
||||
function htmlToMarkdown(input) {
|
||||
if (!input) return "";
|
||||
|
||||
let html = "";
|
||||
if (typeof input === "string") html = input;
|
||||
else if (input?.p) {
|
||||
if (typeof input.p === "string") html = `<p>${input.p}</p>`;
|
||||
else if (Array.isArray(input.p))
|
||||
html = input.p.map((p) => `<p>${p}</p>`).join("");
|
||||
} else html = String(input);
|
||||
|
||||
let s = decodeEntities(html);
|
||||
|
||||
s = s
|
||||
.replace(/<\s*(ul|ol)[^>]*>/gi, "\n__LIST_START__\n")
|
||||
.replace(/<\/\s*(ul|ol)\s*>/gi, "\n__LIST_END__\n")
|
||||
.replace(/<\s*li[^>]*>/gi, "__LI__")
|
||||
.replace(/<\/\s*li\s*>/gi, "\n")
|
||||
.replace(/<\s*br\s*\/?\s*>/gi, "\n")
|
||||
.replace(/<\/\s*(p|div)\s*>/gi, "\n")
|
||||
.replace(/<\s*(p|div)[^>]*>/gi, "")
|
||||
.replace(/<[^>]+>/g, "")
|
||||
.replace(/\r/g, "")
|
||||
.replace(/[ \t]+\n/g, "\n")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
|
||||
const lines = s.split("\n").map((x) => x.trim());
|
||||
const out = [];
|
||||
let inList = false;
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line) {
|
||||
if (!inList) out.push("");
|
||||
continue;
|
||||
}
|
||||
if (line === "__LIST_START__") {
|
||||
inList = true;
|
||||
continue;
|
||||
}
|
||||
if (line === "__LIST_END__") {
|
||||
inList = false;
|
||||
out.push("");
|
||||
continue;
|
||||
}
|
||||
if (inList && line.startsWith("__LI__")) {
|
||||
out.push(`- ${line.replace("__LI__", "").trim()}`);
|
||||
continue;
|
||||
}
|
||||
out.push(line);
|
||||
}
|
||||
|
||||
return out.join("\n").trim();
|
||||
}
|
||||
|
||||
function extractLogoUrl(node) {
|
||||
const logo = node?.field_logo_fid;
|
||||
if (!logo) return null;
|
||||
|
||||
// w listakanalow.xml jest zwykle bezpośredni URL
|
||||
if (typeof logo === "string") {
|
||||
const s = logo.trim();
|
||||
if (!s) return null;
|
||||
const m = s.match(/src="([^"]+)"/);
|
||||
return m?.[1] ?? s;
|
||||
}
|
||||
|
||||
if (logo?.img?.["@_src"]) return logo.img["@_src"];
|
||||
return null;
|
||||
}
|
||||
|
||||
async function downloadLogoAsBase64(url) {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) return null;
|
||||
const ct = res.headers.get("content-type") || "image/png";
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
if (!buf.length) return null;
|
||||
return `data:${ct};base64,${buf.toString("base64")}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureTidColumn(db) {
|
||||
const cols = db
|
||||
.prepare("PRAGMA table_info(jambox_channels)")
|
||||
.all()
|
||||
.map((r) => String(r.name || "").toLowerCase());
|
||||
|
||||
if (!cols.includes("tid")) {
|
||||
db.exec("ALTER TABLE jambox_channels ADD COLUMN tid INTEGER;");
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST({ request }) {
|
||||
if (!isAuthorized(request)) {
|
||||
return new Response(JSON.stringify({ ok: false, error: "Unauthorized" }), {
|
||||
status: 401,
|
||||
headers: { "content-type": "application/json; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const dryRun = body?.dryRun === true;
|
||||
|
||||
const YAML_PATH =
|
||||
import.meta.env.JAMBOX_TV_ADDONS_YAML_PATH ||
|
||||
path.join(
|
||||
process.cwd(),
|
||||
"src",
|
||||
"content",
|
||||
"internet-telewizja",
|
||||
"tv-addons.yaml",
|
||||
);
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// tabela + unikalność do ON CONFLICT
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS jambox_channels (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
nazwa TEXT NOT NULL,
|
||||
pckg_name TEXT NOT NULL,
|
||||
image TEXT,
|
||||
opis TEXT,
|
||||
pckg_addons INTEGER NOT NULL DEFAULT (0)
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_jambox_channels_name_pkg
|
||||
ON jambox_channels(nazwa, pckg_name);
|
||||
`);
|
||||
|
||||
// ✅ dorób kolumnę tid (dla addonów)
|
||||
ensureTidColumn(db);
|
||||
|
||||
const delAddons = db.prepare(
|
||||
`DELETE FROM jambox_channels WHERE pckg_addons = 1`,
|
||||
);
|
||||
|
||||
// ✅ zapisujemy pckg_addons=1 i tid
|
||||
const upsert = db.prepare(`
|
||||
INSERT INTO jambox_channels (nazwa, pckg_name, image, opis, pckg_addons, tid)
|
||||
VALUES (@nazwa, @pckg_name, @image, @opis, 1, @tid)
|
||||
ON CONFLICT(nazwa, pckg_name) DO UPDATE SET
|
||||
image = COALESCE(excluded.image, jambox_channels.image),
|
||||
opis = COALESCE(excluded.opis, jambox_channels.opis),
|
||||
pckg_addons = 1,
|
||||
tid = COALESCE(excluded.tid, jambox_channels.tid)
|
||||
`);
|
||||
|
||||
const logoCache = new Map();
|
||||
const rows = [];
|
||||
|
||||
try {
|
||||
// 1) YAML -> tid dodatku
|
||||
const rawYaml = await fs.readFile(YAML_PATH, "utf8");
|
||||
const doc = yaml.load(rawYaml);
|
||||
|
||||
if (!doc || !Array.isArray(doc.dodatki)) {
|
||||
return new Response(
|
||||
JSON.stringify({ ok: false, error: "YAML: brak doc.dodatki" }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "content-type": "application/json; charset=utf-8" },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// tid -> nazwa pakietu (z YAML)
|
||||
const addonTidToName = new Map();
|
||||
for (const a of doc.dodatki) {
|
||||
const tid = toInt(a?.tid);
|
||||
const name = String(a?.nazwa ?? "").trim();
|
||||
if (tid != null && name) addonTidToName.set(tid, name);
|
||||
}
|
||||
const addonTids = new Set(addonTidToName.keys());
|
||||
|
||||
// 2) XML kanałów
|
||||
const xml = await fetchXml(CHANNELS_URL);
|
||||
const nodes = parseNodes(xml);
|
||||
|
||||
for (const node of nodes) {
|
||||
const channelName = String(
|
||||
node?.nazwa_kanalu ?? node?.nazwa ?? "",
|
||||
).trim();
|
||||
if (!channelName) continue;
|
||||
|
||||
const pkgIds = parseCsvIds(node?.pakiety_id);
|
||||
if (!pkgIds.length) continue;
|
||||
|
||||
// interesują nas tylko te pakiety_id, które są w YAML dodatków (tid)
|
||||
const matchedAddonTids = pkgIds.filter((tid) => addonTids.has(tid));
|
||||
if (!matchedAddonTids.length) continue;
|
||||
|
||||
const opis = htmlToMarkdown(node?.opis) || null;
|
||||
|
||||
const key = channelName.toLowerCase();
|
||||
let img = logoCache.get(key);
|
||||
if (img === undefined) {
|
||||
const logoUrl = extractLogoUrl(node);
|
||||
img = logoUrl ? await downloadLogoAsBase64(logoUrl) : null;
|
||||
logoCache.set(key, img);
|
||||
}
|
||||
|
||||
for (const tid of matchedAddonTids) {
|
||||
const pckgName = addonTidToName.get(tid);
|
||||
if (!pckgName) continue;
|
||||
|
||||
rows.push({
|
||||
nazwa: channelName,
|
||||
pckg_name: pckgName,
|
||||
image: img,
|
||||
opis,
|
||||
tid, // ✅ kluczowe: zapis tid do bazy
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 3) REFRESH: usuń wszystkie addonowe i wrzuć nowe
|
||||
if (!dryRun) {
|
||||
const trx = db.transaction((data) => {
|
||||
const info = delAddons.run();
|
||||
for (const r of data) upsert.run(r);
|
||||
return info.changes;
|
||||
});
|
||||
|
||||
const deleted = trx(rows);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
dryRun,
|
||||
deleted_addon_rows: deleted,
|
||||
inserted_rows: rows.length,
|
||||
db: DB_PATH,
|
||||
yaml: YAML_PATH,
|
||||
uniqueAddonsUsed: addonTidToName.size,
|
||||
tidsUsed: addonTids.size,
|
||||
}),
|
||||
{ headers: { "content-type": "application/json; charset=utf-8" } },
|
||||
);
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
dryRun: true,
|
||||
would_delete_addon_rows: "(unknown in dryRun)",
|
||||
would_insert_rows: rows.length,
|
||||
db: DB_PATH,
|
||||
yaml: YAML_PATH,
|
||||
uniqueAddonsUsed: addonTidToName.size,
|
||||
tidsUsed: addonTids.size,
|
||||
}),
|
||||
{ headers: { "content-type": "application/json; charset=utf-8" } },
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("import addon jambox_channels:", e);
|
||||
return new Response(
|
||||
JSON.stringify({ ok: false, error: String(e?.message || e) }),
|
||||
{
|
||||
status: 500,
|
||||
headers: { "content-type": "application/json; charset=utf-8" },
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
try {
|
||||
db.close();
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
291
src/pages/api/jambox/import-jambox-tematyczne.js
Normal file
291
src/pages/api/jambox/import-jambox-tematyczne.js
Normal file
@@ -0,0 +1,291 @@
|
||||
import path from "node:path";
|
||||
import fs from "node:fs/promises";
|
||||
import yaml from "js-yaml";
|
||||
import { XMLParser } from "fast-xml-parser";
|
||||
|
||||
const PLUS_THEMATIC_URL =
|
||||
"https://www.jambox.pl/xml/slownik-pakietytematyczneplus.xml";
|
||||
const PREMIUM_URL = "https://www.jambox.pl/xml/slownik-pakietypremium.xml";
|
||||
|
||||
/**
|
||||
* .env: JAMBOX_ADMIN_TOKEN="..."
|
||||
*/
|
||||
function isAuthorized(request) {
|
||||
const expected = import.meta.env.JAMBOX_ADMIN_TOKEN;
|
||||
if (!expected) return false;
|
||||
const token = request.headers.get("x-admin-token");
|
||||
return token === expected;
|
||||
}
|
||||
|
||||
async function fetchXml(url) {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`Błąd pobierania XML: ${res.status} ${res.statusText} (${url})`,
|
||||
);
|
||||
}
|
||||
return await res.text();
|
||||
}
|
||||
|
||||
/**
|
||||
* XML:
|
||||
* <xml>
|
||||
* <node><name>...</name><tid>...</tid></node>
|
||||
* <node>...</node>
|
||||
* </xml>
|
||||
*/
|
||||
function parseNodes(xmlText) {
|
||||
const parser = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
attributeNamePrefix: "@_",
|
||||
});
|
||||
|
||||
const json = parser.parse(xmlText);
|
||||
const root = json.xml ?? json;
|
||||
|
||||
let nodes = root?.node ?? [];
|
||||
if (!nodes) return [];
|
||||
if (!Array.isArray(nodes)) nodes = [nodes];
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function toInt(v) {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function normalizeName(name) {
|
||||
let s = String(name || "").trim();
|
||||
|
||||
// często XML ma "Pakiet ..." a u Ciebie w YAML jest bez "Pakiet"
|
||||
s = s.replace(/^pakiet\s+/i, "");
|
||||
|
||||
// "+Max" -> "+ Max"
|
||||
s = s.replace(/\+([A-Za-zĄĆĘŁŃÓŚŹŻ])/g, "+ $1");
|
||||
|
||||
// pojedyncze spacje
|
||||
s = s.replace(/\s+/g, " ").trim();
|
||||
|
||||
return s;
|
||||
}
|
||||
|
||||
function stripDiacritics(s) {
|
||||
return String(s || "")
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "");
|
||||
}
|
||||
|
||||
function slugifyId(name) {
|
||||
const s = stripDiacritics(String(name || "").toLowerCase())
|
||||
.replace(/\+/g, " plus ")
|
||||
.replace(/&/g, " and ")
|
||||
.replace(/[^a-z0-9]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "")
|
||||
.replace(/_+/g, "_");
|
||||
return s || "pakiet";
|
||||
}
|
||||
|
||||
function buildXmlMap(nodesA, nodesB) {
|
||||
const byTid = new Map();
|
||||
const byNormName = new Map();
|
||||
|
||||
function add(nodes, source) {
|
||||
for (const n of nodes || []) {
|
||||
const tid = toInt(n?.tid);
|
||||
const rawName = String(n?.name ?? "").trim();
|
||||
const name = normalizeName(rawName);
|
||||
|
||||
if (!tid || !name) continue;
|
||||
|
||||
if (!byTid.has(tid)) byTid.set(tid, { tid, name, rawName, source });
|
||||
|
||||
const key = stripDiacritics(name).toLowerCase();
|
||||
if (!byNormName.has(key)) byNormName.set(key, { tid, name, rawName, source });
|
||||
}
|
||||
}
|
||||
|
||||
add(nodesA, "thematic_plus");
|
||||
add(nodesB, "premium");
|
||||
|
||||
return { byTid, byNormName };
|
||||
}
|
||||
|
||||
export async function POST({ request }) {
|
||||
try {
|
||||
if (!isAuthorized(request)) {
|
||||
return new Response(JSON.stringify({ ok: false, error: "Unauthorized" }), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const dryRun = body?.dryRun === true;
|
||||
|
||||
// domyślnie: tylko aktualizacja (bez dopisywania nowych)
|
||||
const addMissing = body?.addMissing === true;
|
||||
const updateNames = body?.updateNames !== false; // default true
|
||||
|
||||
const YAML_PATH =
|
||||
import.meta.env.JAMBOX_TV_ADDONS_YAML_PATH ||
|
||||
path.join(
|
||||
process.cwd(),
|
||||
"src",
|
||||
"content",
|
||||
"internet-telewizja",
|
||||
"tv-addons.yaml",
|
||||
);
|
||||
|
||||
// 1) XML
|
||||
const [xmlA, xmlB] = await Promise.all([
|
||||
fetchXml(PLUS_THEMATIC_URL),
|
||||
fetchXml(PREMIUM_URL),
|
||||
]);
|
||||
|
||||
const nodesA = parseNodes(xmlA);
|
||||
const nodesB = parseNodes(xmlB);
|
||||
|
||||
const { byTid, byNormName } = buildXmlMap(nodesA, nodesB);
|
||||
|
||||
// 2) YAML
|
||||
const rawYaml = await fs.readFile(YAML_PATH, "utf8");
|
||||
const doc = yaml.load(rawYaml);
|
||||
|
||||
if (!doc || !Array.isArray(doc.dodatki)) {
|
||||
return new Response(
|
||||
JSON.stringify({ ok: false, error: "YAML: brak doc.dodatki" }),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
const changedItems = [];
|
||||
const unchangedItems = [];
|
||||
const skippedItems = [];
|
||||
const addedItems = [];
|
||||
|
||||
// indeksy istniejących (dla addMissing)
|
||||
const existingByTid = new Map();
|
||||
const existingByNormName = new Map();
|
||||
|
||||
for (const a of doc.dodatki) {
|
||||
const tid = toInt(a?.tid);
|
||||
const nm = normalizeName(String(a?.nazwa ?? ""));
|
||||
if (tid != null) existingByTid.set(tid, a);
|
||||
if (nm) existingByNormName.set(stripDiacritics(nm).toLowerCase(), a);
|
||||
}
|
||||
|
||||
// 2a) aktualizacje
|
||||
for (const addon of doc.dodatki) {
|
||||
const id = addon?.id ?? null;
|
||||
|
||||
const before = {
|
||||
tid: toInt(addon?.tid),
|
||||
nazwa: String(addon?.nazwa ?? ""),
|
||||
};
|
||||
|
||||
let match = null;
|
||||
|
||||
// najpierw po tid (najpewniejsze)
|
||||
if (before.tid != null && byTid.has(before.tid)) {
|
||||
match = byTid.get(before.tid);
|
||||
} else {
|
||||
// potem po nazwie
|
||||
const key = stripDiacritics(normalizeName(before.nazwa)).toLowerCase();
|
||||
if (key && byNormName.has(key)) match = byNormName.get(key);
|
||||
}
|
||||
|
||||
if (!match) {
|
||||
skippedItems.push({ id, reason: "brak dopasowania w XML", before });
|
||||
continue;
|
||||
}
|
||||
|
||||
const after = {
|
||||
tid: match.tid,
|
||||
nazwa: updateNames ? match.name : before.nazwa,
|
||||
};
|
||||
|
||||
const willChange =
|
||||
before.tid !== after.tid || before.nazwa !== after.nazwa;
|
||||
|
||||
if (!willChange) {
|
||||
unchangedItems.push({ id, tid: before.tid, nazwa: before.nazwa });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!dryRun) {
|
||||
addon.tid = after.tid;
|
||||
if (updateNames) addon.nazwa = after.nazwa;
|
||||
}
|
||||
|
||||
changedItems.push({
|
||||
id,
|
||||
before,
|
||||
after,
|
||||
matchedFrom: match.source,
|
||||
});
|
||||
}
|
||||
|
||||
// 2b) opcjonalnie dopisz brakujące pakiety z XML do YAML
|
||||
if (addMissing) {
|
||||
for (const [tid, it] of byTid.entries()) {
|
||||
if (existingByTid.has(tid)) continue;
|
||||
|
||||
const key = stripDiacritics(it.name).toLowerCase();
|
||||
if (existingByNormName.has(key)) continue;
|
||||
|
||||
const newId = slugifyId(it.name);
|
||||
|
||||
const newItem = {
|
||||
id: newId,
|
||||
nazwa: it.name,
|
||||
tid: tid,
|
||||
typ: "checkbox",
|
||||
opis: "",
|
||||
cena: [],
|
||||
};
|
||||
|
||||
if (!dryRun) doc.dodatki.push(newItem);
|
||||
|
||||
addedItems.push({ id: newId, tid, nazwa: it.name, source: it.source });
|
||||
}
|
||||
}
|
||||
|
||||
if (!dryRun && (changedItems.length > 0 || addedItems.length > 0)) {
|
||||
const newYaml = yaml.dump(doc, {
|
||||
lineWidth: -1,
|
||||
noRefs: true,
|
||||
sortKeys: false,
|
||||
});
|
||||
await fs.writeFile(YAML_PATH, newYaml, "utf8");
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
dryRun,
|
||||
yamlPath: YAML_PATH,
|
||||
options: { addMissing, updateNames },
|
||||
xmlCounts: {
|
||||
thematic_plus: nodesA.length,
|
||||
premium: nodesB.length,
|
||||
uniqueByTid: byTid.size,
|
||||
},
|
||||
changed: changedItems.length,
|
||||
added: addedItems.length,
|
||||
unchanged: unchangedItems.length,
|
||||
skipped: skippedItems.length,
|
||||
changedItems,
|
||||
addedItems,
|
||||
skippedItems,
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("update-tv-addons error:", err);
|
||||
return new Response(
|
||||
JSON.stringify({ ok: false, error: String(err?.message ?? err) }),
|
||||
{ status: 500, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,18 @@ function slugifyPkg(name) {
|
||||
.replace(/(^-|-$)/g, "");
|
||||
}
|
||||
|
||||
function uniqByKey(list, keyFn) {
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const it of list || []) {
|
||||
const k = keyFn(it);
|
||||
if (!k || seen.has(k)) continue;
|
||||
seen.add(k);
|
||||
out.push(it);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function GET({ url }) {
|
||||
const q = (url.searchParams.get("q") || "").trim();
|
||||
const limit = clamp(Number(url.searchParams.get("limit") || 50), 1, 200);
|
||||
@@ -44,10 +56,25 @@ export function GET({ url }) {
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
nazwa AS name,
|
||||
MAX(image) AS logo_url,
|
||||
MAX(opis) AS description,
|
||||
GROUP_CONCAT(pckg_name, '||') AS packages_blob
|
||||
nazwa AS name,
|
||||
MAX(image) AS logo_url,
|
||||
MAX(opis) AS description,
|
||||
|
||||
-- ✅ pakiety główne (pckg_addons = 0)
|
||||
GROUP_CONCAT(
|
||||
CASE WHEN IFNULL(pckg_addons, 0) = 0 THEN pckg_name END,
|
||||
'||'
|
||||
) AS packages_blob,
|
||||
|
||||
-- ✅ pakiety tematyczne (pckg_addons = 1) + tid
|
||||
GROUP_CONCAT(
|
||||
CASE
|
||||
WHEN IFNULL(pckg_addons, 0) = 1 AND tid IS NOT NULL
|
||||
THEN CAST(tid AS TEXT) || '::' || pckg_name
|
||||
END,
|
||||
'||'
|
||||
) AS thematic_blob
|
||||
|
||||
FROM jambox_channels
|
||||
WHERE nazwa LIKE ? ESCAPE '\\'
|
||||
GROUP BY nazwa
|
||||
@@ -58,23 +85,40 @@ export function GET({ url }) {
|
||||
.all(like, limit);
|
||||
|
||||
const data = rows.map((r) => {
|
||||
// ===== główne =====
|
||||
const pkgsRaw = String(r.packages_blob || "")
|
||||
.split("||")
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const packages = uniq(pkgsRaw)
|
||||
.map((p) => ({
|
||||
id: slugifyPkg(p),
|
||||
name: p,
|
||||
}))
|
||||
.map((p) => ({ id: slugifyPkg(p), name: p }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name, "pl"));
|
||||
|
||||
// ===== tematyczne =====
|
||||
const thematicRaw = String(r.thematic_blob || "")
|
||||
.split("||")
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const thematic_packages = uniqByKey(
|
||||
thematicRaw
|
||||
.map((x) => {
|
||||
const [tid, ...rest] = x.split("::");
|
||||
const name = rest.join("::").trim();
|
||||
const t = String(tid || "").trim();
|
||||
return t && name ? { tid: t, name } : null;
|
||||
})
|
||||
.filter(Boolean),
|
||||
(p) => `${p.tid}::${p.name}`
|
||||
).sort((a, b) => a.name.localeCompare(b.name, "pl"));
|
||||
|
||||
return {
|
||||
name: r.name,
|
||||
logo_url: r.logo_url || "",
|
||||
description: r.description || "",
|
||||
packages,
|
||||
thematic_packages, // ✅ NOWE
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
90
src/pages/internet-telewizja/pakiety-tematyczne.astro
Normal file
90
src/pages/internet-telewizja/pakiety-tematyczne.astro
Normal file
@@ -0,0 +1,90 @@
|
||||
---
|
||||
import DefaultLayout from "../../layouts/DefaultLayout.astro";
|
||||
import yaml from "js-yaml";
|
||||
import fs from "node:fs";
|
||||
import Markdown from "../../islands/Markdown.jsx";
|
||||
import AddonChannelsGrid from "../../islands/jambox/AddonChannelsModal.jsx";
|
||||
import "../../styles/jambox-tematyczne.css";
|
||||
|
||||
/** Typy minimalne */
|
||||
type AddonPriceRow = {
|
||||
pakiety?: string[] | any;
|
||||
"12m"?: number | string;
|
||||
bezterminowo?: number | string;
|
||||
};
|
||||
|
||||
type TvAddon = {
|
||||
id?: string;
|
||||
nazwa?: string;
|
||||
tid?: number;
|
||||
typ?: string;
|
||||
opis?: string;
|
||||
image?: string;
|
||||
cena?: AddonPriceRow[];
|
||||
};
|
||||
|
||||
type TvAddonsDoc = {
|
||||
tytul?: string;
|
||||
opis?: string;
|
||||
cena_opis?: string;
|
||||
dodatki?: TvAddon[];
|
||||
};
|
||||
|
||||
const doc = yaml.load(
|
||||
fs.readFileSync("./src/content/internet-telewizja/tv-addons.yaml", "utf8"),
|
||||
) as TvAddonsDoc;
|
||||
|
||||
const pageTitle = doc?.tytul ?? "Dodatkowe pakiety TV";
|
||||
const pageDesc = doc?.opis ?? "";
|
||||
const addons: TvAddon[] = Array.isArray(doc?.dodatki) ? doc.dodatki : [];
|
||||
---
|
||||
|
||||
<DefaultLayout title={pageTitle} description={pageDesc}>
|
||||
<section class="f-section">
|
||||
<div class="f-section-grid-single">
|
||||
<h1 class="f-section-title">{pageTitle}</h1>
|
||||
{pageDesc && <Markdown text={pageDesc} />}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{addons.map((addon: TvAddon, index: number) => {
|
||||
const isAboveFold = index === 0;
|
||||
|
||||
const hasYamlImage = !!String(addon?.image ?? "").trim();
|
||||
const pkgName = String(addon?.nazwa ?? "").trim();
|
||||
|
||||
const assumeHasMedia = pkgName ? true : hasYamlImage;
|
||||
const anchorId = addon?.tid != null ? String(addon.tid) : undefined;
|
||||
|
||||
return (
|
||||
<section class="f-section">
|
||||
<div
|
||||
class={`f-section-grid f-addon-grid f-addon-section ${
|
||||
assumeHasMedia ? "md:grid-cols-2" : "md:grid-cols-1"
|
||||
}`}
|
||||
data-addon-section
|
||||
data-has-media={assumeHasMedia ? "1" : "0"}
|
||||
>
|
||||
{/* TEKST — lewa, od góry */}
|
||||
<div class="f-addon-text" id={anchorId}>
|
||||
{pkgName && <h2 class="f-section-title">{pkgName}</h2>}
|
||||
{addon?.opis && <Markdown text={addon.opis} />}
|
||||
</div>
|
||||
|
||||
{/* MEDIA — prawa */}
|
||||
<div class="f-addon-media">
|
||||
{pkgName ? (
|
||||
<AddonChannelsGrid
|
||||
client:idle
|
||||
packageName={pkgName}
|
||||
fallbackImage={String(addon?.image ?? "")}
|
||||
aboveFold={isAboveFold}
|
||||
title={pkgName}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</DefaultLayout>
|
||||
Reference in New Issue
Block a user