Skip to content

Commit f1493eb

Browse files
committed
core: centralize stream metadata presentation
1 parent 56871ba commit f1493eb

1 file changed

Lines changed: 177 additions & 0 deletions

File tree

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
const UNKNOWN = /^(?:unknown|n\/a|na|none|null|undefined|-)$/i;
2+
3+
export function presentStreamCandidates(streams, metadata = {}, provider = {}) {
4+
return (Array.isArray(streams) ? streams : []).map((stream) => presentStreamCandidate(stream, metadata, provider));
5+
}
6+
7+
export function presentStreamCandidate(stream = {}, metadata = {}, provider = {}) {
8+
const facts = collectFacts(stream, metadata);
9+
const providerName = clean(stream.name) ?? clean(provider.name) ?? clean(provider.id) ?? clean(stream.provider) ?? "Source";
10+
const originalDescription = useful(stream.description);
11+
const badges = buildBadges(facts);
12+
const descriptionParts = [];
13+
14+
if (badges.length) descriptionParts.push(badges.join(" • "));
15+
if (originalDescription && !descriptionParts.some((part) => part.includes(originalDescription))) {
16+
descriptionParts.push(originalDescription);
17+
}
18+
19+
const tmdbTitle = useful(metadata.title ?? metadata.name ?? metadata.originalTitle ?? metadata.original_name);
20+
const tmdbYear = positiveInt(metadata.year ?? yearFromDate(metadata.releaseDate ?? metadata.release_date ?? metadata.firstAirDate ?? metadata.first_air_date));
21+
if (!descriptionParts.length && (tmdbTitle || tmdbYear)) {
22+
descriptionParts.push([tmdbTitle, tmdbYear].filter(Boolean).join(" • "));
23+
}
24+
25+
return {
26+
...stream,
27+
title: providerName,
28+
description: descriptionParts.join("\n") || null,
29+
quality: facts.quality,
30+
language: facts.language,
31+
codec: facts.codec,
32+
audio: facts.audio,
33+
duration: facts.duration,
34+
sourceType: facts.sourceType,
35+
ageRating: facts.ageRating,
36+
displayBadges: badges,
37+
};
38+
}
39+
40+
export function collectFacts(stream = {}, metadata = {}) {
41+
return {
42+
quality: normalizeQuality(stream.quality ?? stream.resolution),
43+
language: useful(stream.language ?? stream.lang ?? stream.audioLanguage),
44+
codec: normalizeCodec(stream.codec ?? stream.videoCodec ?? stream.video_codec),
45+
audio: normalizeAudio(stream.audio ?? stream.audioCodec ?? stream.audio_codec),
46+
duration: normalizeDuration(
47+
stream.duration ?? stream.durationMinutes ?? stream.runtime ??
48+
metadata.duration ?? metadata.durationMinutes ?? metadata.runtime,
49+
),
50+
sourceType: normalizeSourceType(stream.sourceType ?? stream.source_type ?? stream.releaseType ?? stream.release_type),
51+
ageRating: normalizeAgeRating(
52+
stream.ageRating ?? stream.age_rating ?? stream.certification ??
53+
metadata.ageRating ?? metadata.age_rating ?? metadata.certification ?? metadata.contentRating ?? metadata.content_rating,
54+
),
55+
};
56+
}
57+
58+
export function buildBadges(facts = {}) {
59+
const out = [];
60+
const quality = normalizeQuality(facts.quality);
61+
const sourceType = normalizeSourceType(facts.sourceType);
62+
const language = useful(facts.language);
63+
const codec = normalizeCodec(facts.codec);
64+
const audio = normalizeAudio(facts.audio);
65+
const duration = normalizeDuration(facts.duration);
66+
const ageRating = normalizeAgeRating(facts.ageRating);
67+
68+
if (quality) out.push(quality === "2160p" ? "【4K】" : `【${quality.toUpperCase()}】`);
69+
if (sourceType) out.push(`【${sourceType}】`);
70+
if (language) out.push(`🌐 ${language.toUpperCase()}`);
71+
if (codec) out.push(`🎞 ${codec}`);
72+
if (audio) out.push(`🔊 ${audio}`);
73+
if (duration) out.push(`⏱ ${formatDuration(duration)}`);
74+
if (ageRating) out.push(`🔞 ${ageRating}`);
75+
return out;
76+
}
77+
78+
export function normalizeQuality(value) {
79+
const text = useful(value);
80+
if (!text) return null;
81+
if (/^(?:4k|uhd|2160p?)$/i.test(text)) return "2160p";
82+
const match = text.match(/(?:^|\b)(2160|1440|1080|720|576|480)p?(?:\b|$)/i);
83+
return match ? `${match[1]}p` : text;
84+
}
85+
86+
export function normalizeSourceType(value) {
87+
const text = useful(value);
88+
if (!text) return null;
89+
const compact = text.toUpperCase().replace(/[._\s]+/g, "-");
90+
if (/BLU-?RAY|BDRIP|BRRIP/.test(compact)) return "BLU-RAY";
91+
if (/WEB-?DL/.test(compact)) return "WEB-DL";
92+
if (/WEB-?RIP/.test(compact)) return "WEBRIP";
93+
if (/HDTV/.test(compact)) return "HDTV";
94+
if (/DVDRIP|DVD/.test(compact)) return "DVD";
95+
if (/CAM|TS|TELESYNC/.test(compact)) return "CAM";
96+
return text.toUpperCase();
97+
}
98+
99+
export function normalizeCodec(value) {
100+
const text = useful(value);
101+
if (!text) return null;
102+
const upper = text.toUpperCase();
103+
if (/H\.?265|X265|HEVC/.test(upper)) return "HEVC";
104+
if (/H\.?264|X264|AVC/.test(upper)) return "H.264";
105+
if (/AV1/.test(upper)) return "AV1";
106+
if (/VP9/.test(upper)) return "VP9";
107+
return text;
108+
}
109+
110+
export function normalizeAudio(value) {
111+
const text = useful(value);
112+
if (!text) return null;
113+
return text
114+
.replace(/\bDDP\b/ig, "E-AC3")
115+
.replace(/\bDD\b/ig, "AC3")
116+
.replace(/\s+/g, " ")
117+
.trim();
118+
}
119+
120+
export function normalizeDuration(value) {
121+
if (value == null || value === "") return null;
122+
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
123+
return value > 600 ? Math.round(value / 60) : Math.round(value);
124+
}
125+
const text = useful(value);
126+
if (!text) return null;
127+
const hm = text.match(/(?:(\d+)\s*h(?:ours?)?)?\s*(?:(\d+)\s*m(?:in(?:utes?)?)?)?/i);
128+
if (hm && (hm[1] || hm[2])) {
129+
const minutes = Number(hm[1] || 0) * 60 + Number(hm[2] || 0);
130+
return minutes > 0 ? minutes : null;
131+
}
132+
const number = Number(text);
133+
if (Number.isFinite(number) && number > 0) return number > 600 ? Math.round(number / 60) : Math.round(number);
134+
return null;
135+
}
136+
137+
export function normalizeAgeRating(value) {
138+
const text = useful(value);
139+
if (!text) return null;
140+
const upper = text.toUpperCase();
141+
const france = upper.match(/(?:-|INTERDIT\s+MOINS\s+DE\s+)(10|12|16|18)\b/);
142+
if (france) return `-${france[1]}`;
143+
const plus = upper.match(/\b(7|10|12|13|14|15|16|17|18)\+\b/);
144+
if (plus) return `${plus[1]}+`;
145+
if (/^(?:U|G|PG|PG-13|R|NC-17|TV-Y|TV-Y7|TV-G|TV-PG|TV-14|TV-MA)$/i.test(text)) return upper;
146+
return text;
147+
}
148+
149+
function formatDuration(minutes) {
150+
const total = Math.max(1, Math.round(Number(minutes)));
151+
const hours = Math.floor(total / 60);
152+
const rest = total % 60;
153+
if (!hours) return `${rest} min`;
154+
return rest ? `${hours}h${String(rest).padStart(2, "0")}` : `${hours}h`;
155+
}
156+
157+
function yearFromDate(value) {
158+
const text = useful(value);
159+
const match = text?.match(/(?:19|20)\d{2}/);
160+
return match ? Number(match[0]) : null;
161+
}
162+
163+
function positiveInt(value) {
164+
const number = Number(value);
165+
return Number.isInteger(number) && number > 0 ? number : null;
166+
}
167+
168+
function useful(value) {
169+
const text = clean(value);
170+
return text && !UNKNOWN.test(text) ? text : null;
171+
}
172+
173+
function clean(value) {
174+
if (value == null) return null;
175+
const text = String(value).trim();
176+
return text || null;
177+
}

0 commit comments

Comments
 (0)