Skip to content

Commit 4d2bf3c

Browse files
committed
automation: add weekly upstream provider discovery
1 parent 41d123d commit 4d2bf3c

1 file changed

Lines changed: 231 additions & 0 deletions

File tree

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
#!/usr/bin/env node
2+
import fs from "node:fs/promises";
3+
import path from "node:path";
4+
import { fileURLToPath, pathToFileURL } from "node:url";
5+
6+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
7+
8+
export function providerKey(value) {
9+
return String(value ?? "")
10+
.normalize("NFKD")
11+
.replace(/[\u0300-\u036f]/g, "")
12+
.toLowerCase()
13+
.replace(/[^a-z0-9]+/g, "")
14+
.trim();
15+
}
16+
17+
export function catalogIdentity(catalog = {}) {
18+
const ids = new Set();
19+
const names = new Set();
20+
for (const row of catalog.providers ?? []) {
21+
if (!row || typeof row !== "object") continue;
22+
for (const value of [row.canonicalId, row.scraper?.id]) {
23+
const key = providerKey(value);
24+
if (key) ids.add(key);
25+
}
26+
const name = providerKey(row.scraper?.name);
27+
if (name) names.add(name);
28+
const filename = String(row.scraper?.filename ?? "");
29+
const stem = path.basename(filename).split("--", 1)[0].replace(/\.js$/i, "");
30+
const stemKey = providerKey(stem);
31+
if (stemKey) ids.add(stemKey);
32+
}
33+
return { ids, names };
34+
}
35+
36+
export function manifestRows(payload = {}) {
37+
const candidates = [payload.scrapers, payload.providers, payload.items];
38+
return candidates.find(Array.isArray) ?? [];
39+
}
40+
41+
export function interestFor(row = {}) {
42+
const reasons = [];
43+
let score = 0;
44+
const languages = list(row.contentLanguage ?? row.languages).map((value) => value.toLowerCase());
45+
const formats = list(row.formats).map((value) => value.toLowerCase());
46+
const types = list(row.supportedTypes).map((value) => value.toLowerCase());
47+
const description = `${row.name ?? ""} ${row.description ?? ""}`.toLowerCase();
48+
49+
if (languages.some((value) => ["fr", "fra", "french", "vf", "vostfr"].includes(value))) {
50+
score += 4;
51+
reasons.push("French/VF metadata");
52+
}
53+
if (types.includes("movie") && (types.includes("tv") || types.includes("anime"))) {
54+
score += 2;
55+
reasons.push("movie + episodic coverage");
56+
} else if (types.length) {
57+
score += 1;
58+
reasons.push(`covers ${types.join("/")}`);
59+
}
60+
const directFormats = formats.filter((value) => ["m3u8", "mp4", "mkv", "mpd", "webm"].includes(value));
61+
if (directFormats.length) {
62+
score += Math.min(3, directFormats.length);
63+
reasons.push(`direct formats: ${directFormats.join(", ")}`);
64+
}
65+
if (/\b(?:4k|uhd|2160p)\b/.test(description)) {
66+
score += 2;
67+
reasons.push("4K/UHD signal");
68+
}
69+
if (/anime|vostfr|french|francais|français/.test(description)) {
70+
score += 1;
71+
reasons.push("catalogue niche/language signal");
72+
}
73+
if (row.enabled !== false) {
74+
score += 1;
75+
reasons.push("enabled upstream");
76+
}
77+
if (row.limited === true) {
78+
score -= 1;
79+
reasons.push("limited upstream");
80+
}
81+
82+
return { score, interesting: score >= 4, reasons };
83+
}
84+
85+
export function compareManifest({ upstream, manifest, catalog }) {
86+
const known = catalogIdentity(catalog);
87+
const unseen = [];
88+
const existing = [];
89+
90+
for (const row of manifestRows(manifest)) {
91+
if (!row || typeof row !== "object") continue;
92+
const id = String(row.id ?? row.name ?? "").trim();
93+
if (!id) continue;
94+
const idKey = providerKey(id);
95+
const nameKey = providerKey(row.name);
96+
const isKnown = known.ids.has(idKey) || (nameKey && known.names.has(nameKey));
97+
const summary = {
98+
upstream: upstream.id,
99+
repository: upstream.repository,
100+
id,
101+
name: row.name ?? id,
102+
version: row.version ?? null,
103+
filename: row.filename ?? null,
104+
supportedTypes: list(row.supportedTypes),
105+
contentLanguage: list(row.contentLanguage ?? row.languages),
106+
formats: list(row.formats),
107+
enabled: row.enabled !== false,
108+
limited: row.limited === true,
109+
};
110+
if (isKnown) {
111+
existing.push(summary);
112+
continue;
113+
}
114+
const interest = interestFor(row);
115+
unseen.push({ ...summary, ...interest });
116+
}
117+
118+
unseen.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id));
119+
return { unseen, existingCount: existing.length };
120+
}
121+
122+
async function readJson(filename) {
123+
return JSON.parse(await fs.readFile(filename, "utf8"));
124+
}
125+
126+
async function fetchManifest(upstream) {
127+
const repositories = [upstream.repository, upstream.fallback_repository].filter(Boolean);
128+
const errors = [];
129+
for (const repository of repositories) {
130+
const url = `https://raw.githubusercontent.com/${repository}/${encodeURIComponent(upstream.branch)}/${upstream.manifest}`;
131+
try {
132+
const response = await fetch(url, {
133+
headers: { "User-Agent": "NiakVIO-upstream-provider-watch/1" },
134+
signal: AbortSignal.timeout(20_000),
135+
});
136+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
137+
const payload = await response.json();
138+
return { payload, repository, url };
139+
} catch (error) {
140+
errors.push(`${repository}: ${error?.message ?? error}`);
141+
}
142+
}
143+
throw new Error(`${upstream.id}: unable to fetch manifest (${errors.join("; ")})`);
144+
}
145+
146+
function markdown(report) {
147+
const lines = [
148+
"# Weekly upstream provider watch",
149+
"",
150+
`Generated: ${report.generatedAt}`,
151+
`New candidates: **${report.summary.newCandidates}** — interesting: **${report.summary.interestingCandidates}**`,
152+
"",
153+
];
154+
for (const source of report.sources) {
155+
lines.push(`## ${source.id}${source.repository}`);
156+
lines.push("");
157+
if (!source.unseen.length) {
158+
lines.push("No provider missing from the NiakVIO catalogue.", "");
159+
continue;
160+
}
161+
lines.push("| Score | Provider | Types | Languages | Formats | Why |", "| ---: | --- | --- | --- | --- | --- |");
162+
for (const row of source.unseen) {
163+
lines.push(`| ${row.score} | ${escapeTable(row.name)} (${escapeTable(row.id)}) | ${escapeTable(row.supportedTypes.join(", ") || "-")} | ${escapeTable(row.contentLanguage.join(", ") || "-")} | ${escapeTable(row.formats.join(", ") || "-")} | ${escapeTable(row.reasons.join("; ") || "new upstream provider")} |`);
164+
}
165+
lines.push("");
166+
}
167+
lines.push("Candidates are observations only. This job never imports or publishes a provider automatically.", "");
168+
return lines.join("\n");
169+
}
170+
171+
function escapeTable(value) {
172+
return String(value ?? "").replace(/\|/g, "\\|").replace(/\s+/g, " ").trim();
173+
}
174+
175+
function list(value) {
176+
return Array.isArray(value) ? value.map((item) => String(item).trim()).filter(Boolean) : value == null ? [] : [String(value).trim()].filter(Boolean);
177+
}
178+
179+
async function main() {
180+
const configPath = process.env.NIAKVIO_PROVIDER_UPSTREAMS ?? path.join(ROOT, "engine_v2/config/provider-upstreams.json");
181+
const catalogPath = process.env.NIAKVIO_PROVIDER_CATALOG ?? path.join(ROOT, "provider_catalog.json");
182+
const outputPath = process.env.NIAKVIO_UPSTREAM_REPORT ?? path.join(ROOT, "health-output/upstream-provider-watch.json");
183+
const markdownPath = process.env.NIAKVIO_UPSTREAM_MARKDOWN ?? path.join(ROOT, "health-output/upstream-provider-watch.md");
184+
185+
const [config, catalog] = await Promise.all([readJson(configPath), readJson(catalogPath)]);
186+
const sources = [];
187+
for (const upstream of config.upstreams ?? []) {
188+
const fetched = await fetchManifest(upstream);
189+
const comparison = compareManifest({ upstream: { ...upstream, repository: fetched.repository }, manifest: fetched.payload, catalog });
190+
sources.push({
191+
id: upstream.id,
192+
repository: fetched.repository,
193+
branch: upstream.branch,
194+
manifest: upstream.manifest,
195+
fetchedFrom: fetched.url,
196+
existingCount: comparison.existingCount,
197+
unseen: comparison.unseen,
198+
});
199+
}
200+
201+
const all = sources.flatMap((source) => source.unseen);
202+
const report = {
203+
schemaVersion: 1,
204+
generatedAt: new Date().toISOString(),
205+
policy: {
206+
importAutomatically: false,
207+
compareAgainst: "provider_catalog.json",
208+
sources: sources.map((source) => source.id),
209+
},
210+
summary: {
211+
newCandidates: all.length,
212+
interestingCandidates: all.filter((row) => row.interesting).length,
213+
},
214+
sources,
215+
};
216+
217+
await fs.mkdir(path.dirname(outputPath), { recursive: true });
218+
await fs.writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`);
219+
await fs.writeFile(markdownPath, markdown(report));
220+
console.log(`upstream provider watch: new=${report.summary.newCandidates} interesting=${report.summary.interestingCandidates}`);
221+
for (const row of all.filter((candidate) => candidate.interesting).slice(0, 30)) {
222+
console.log(`interesting: ${row.upstream}/${row.id} score=${row.score} ${row.reasons.join("; ")}`);
223+
}
224+
}
225+
226+
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
227+
main().catch((error) => {
228+
console.error(error?.stack ?? error);
229+
process.exitCode = 1;
230+
});
231+
}

0 commit comments

Comments
 (0)