holo-evicted-publish: player +5 object(s)
Browse files- b/23e4f84b126619414ef90eba73a97fc398e2629b8ced8c90d5390df35caaca26 +222 -0
- b/52cf82d28c69b7ff9b5ae3df553c65957cf10295013dd6837a1c78e427874ef5 +16 -0
- b/5f654b793ccf0c8d9b4e6bc530dd15f7a032fd440f7bb353496cbf6692b7374f +114 -0
- b/a43249f4f27206cc010c369c220b1469ee0564c4d75f798d63d2c18425d063be +226 -0
- b/e8e3471220a882a17984b9b6efb6a498bb3c82fcdb7a3547dc3905eed732c2fe +119 -0
b/23e4f84b126619414ef90eba73a97fc398e2629b8ced8c90d5390df35caaca26
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// holo-media-item.mjs β the ONE render-item shape for Holo Player, and the TMDb β item normalizers.
|
| 2 |
+
//
|
| 3 |
+
// Holo Player has always had a single item shape (holo-jellyfin.js: native ΞΊ-store + real Jellyfin).
|
| 4 |
+
// This module is the SOURCE OF TRUTH for that shape's superset β it adds, additively:
|
| 5 |
+
// β’ a series hierarchy (kind: movie|series|season|episode; seriesId/seasonNumber/episodeNumber/parentId)
|
| 6 |
+
// β’ a rich metadata block (overview, genres, cast, rating, backdrop, logo, tagline, trailer)
|
| 7 |
+
// β’ "where to watch" (providers[] from TMDb watch-providers β the Netflix/Hulu *feel*)
|
| 8 |
+
// β’ honest availability (metadata β bytes: a title is browsable without being playable)
|
| 9 |
+
// Every field the existing player already reads (name, topics, channel, quality, posterUrl, backdrop,
|
| 10 |
+
// blurb, runtimeSec, kappa, playSrc, source, provider, license β¦) is preserved, so pool()/HoloRank/rails
|
| 11 |
+
// consume a TMDb item with ZERO rail-code change. The shape IS the contract.
|
| 12 |
+
//
|
| 13 |
+
// Pure ESM, no globals, no network β so Node can witness the normalizers exactly. holo-tmdb.mjs wires the
|
| 14 |
+
// network + ΞΊ-cache on top; this module never fetches.
|
| 15 |
+
|
| 16 |
+
// TMDb genre id β human name (movie + tv ids unioned). Used to label rails and feed affinity.
|
| 17 |
+
export const TMDB_GENRES = {
|
| 18 |
+
28: "Action", 12: "Adventure", 16: "Animation", 35: "Comedy", 80: "Crime", 99: "Documentary",
|
| 19 |
+
18: "Drama", 10751: "Family", 14: "Fantasy", 36: "History", 27: "Horror", 10402: "Music",
|
| 20 |
+
9648: "Mystery", 10749: "Romance", 878: "Science Fiction", 10770: "TV Movie", 53: "Thriller",
|
| 21 |
+
10752: "War", 37: "Western", 10759: "Action & Adventure", 10762: "Kids", 10763: "News",
|
| 22 |
+
10764: "Reality", 10765: "Sci-Fi & Fantasy", 10766: "Soap", 10767: "Talk", 10768: "War & Politics",
|
| 23 |
+
};
|
| 24 |
+
|
| 25 |
+
// Human genre β the player's existing topic keys (so genre rails + HoloRank affinity light up). Genres
|
| 26 |
+
// without a legacy key still flow through as their lowercased name (the player makes genre rails dynamic).
|
| 27 |
+
const GENRE_TO_TOPIC = {
|
| 28 |
+
"Science Fiction": "scifi", "Sci-Fi & Fantasy": "scifi", Animation: "animation", Fantasy: "fantasy",
|
| 29 |
+
Comedy: "comedy", Action: "action", "Action & Adventure": "action", Music: "music",
|
| 30 |
+
Documentary: "nature", Family: "comedy", Adventure: "action",
|
| 31 |
+
};
|
| 32 |
+
|
| 33 |
+
// TMDb image CDN β no API key needed for images. Sized per surface so cards are crisp without overfetch.
|
| 34 |
+
export const TMDB_IMG = "https://image.tmdb.org/t/p";
|
| 35 |
+
export const posterURL = (p, w = "w500") => (p ? `${TMDB_IMG}/${w}${p}` : null);
|
| 36 |
+
export const backdropURL = (p, w = "w1280") => (p ? `${TMDB_IMG}/${w}${p}` : null);
|
| 37 |
+
// Key-free artwork fallback (Stremio metahub, by IMDb id) β gives every catalog title real, beautiful
|
| 38 |
+
// posters/backdrops with NO TMDb key (the same CDN Cinemeta uses). Used only when TMDb ships no image
|
| 39 |
+
// (the offline seed): a live TMDb fetch, which has its own poster_path, always wins.
|
| 40 |
+
export const METAHUB = "https://images.metahub.space";
|
| 41 |
+
export const metahubPoster = (imdb) => (imdb && /^tt\d+$/.test(imdb) ? `${METAHUB}/poster/medium/${imdb}/img` : null);
|
| 42 |
+
export const metahubBackdrop = (imdb) => (imdb && /^tt\d+$/.test(imdb) ? `${METAHUB}/background/medium/${imdb}/img` : null);
|
| 43 |
+
const yearOf = (d) => (d && /^\d{4}/.test(d) ? +d.slice(0, 4) : null);
|
| 44 |
+
|
| 45 |
+
const genreNames = (t) => {
|
| 46 |
+
if (Array.isArray(t.genres)) return t.genres.map((g) => g.name).filter(Boolean);
|
| 47 |
+
if (Array.isArray(t.genre_ids)) return t.genre_ids.map((id) => TMDB_GENRES[id]).filter(Boolean);
|
| 48 |
+
return [];
|
| 49 |
+
};
|
| 50 |
+
// topics = legacy keys (for affinity/genre rails) βͺ lowercased raw genres (for dynamic rails). Deduped.
|
| 51 |
+
const topicsOf = (names) => [...new Set(names.flatMap((n) => [GENRE_TO_TOPIC[n], n.toLowerCase()]).filter(Boolean))];
|
| 52 |
+
|
| 53 |
+
// The honest seam: metadata is not bytes. A freshly-discovered title is browsable, not playable, until a
|
| 54 |
+
// source binds (Phase 2). Callers can pass a pre-resolved source; default = browse-only.
|
| 55 |
+
export const browseOnly = () => ({ playable: false, source: null, kappa: "", playSrc: "", type: "" });
|
| 56 |
+
|
| 57 |
+
// ββ deep-metadata extractors (tolerant: return empty when the append_to_response field is absent) ββββββββββ
|
| 58 |
+
export const DEFAULT_REGION = "US";
|
| 59 |
+
const headshot = (p) => (p ? posterURL(p, "w185") : null);
|
| 60 |
+
// official trailer/teaser key (YouTube) for the ambient hero.
|
| 61 |
+
export function trailerOf(t) {
|
| 62 |
+
const v = (t.videos && t.videos.results) || [];
|
| 63 |
+
const f = v.find((x) => x.site === "YouTube" && /Trailer/i.test(x.type)) || v.find((x) => x.site === "YouTube" && /Teaser/i.test(x.type)) || v.find((x) => x.site === "YouTube");
|
| 64 |
+
return f ? f.key : (t.trailerKey || null);
|
| 65 |
+
}
|
| 66 |
+
function castOf(t) { return ((t.credits && t.credits.cast) || []).slice(0, 12).map((p) => ({ id: p.id, name: p.name, character: p.character || "", profile: headshot(p.profile_path) })); }
|
| 67 |
+
function crewOf(t, kind) {
|
| 68 |
+
if (kind === "series") return ((t.created_by) || []).map((p) => p.name);
|
| 69 |
+
return ((t.credits && t.credits.crew) || []).filter((p) => p.job === "Director").map((p) => p.name);
|
| 70 |
+
}
|
| 71 |
+
function certOf(t, kind, region = DEFAULT_REGION) {
|
| 72 |
+
if (kind === "series") { const r = (t.content_ratings && t.content_ratings.results) || []; const m = r.find((x) => x.iso_3166_1 === region) || r[0]; return (m && m.rating) || ""; }
|
| 73 |
+
const rd = (t.release_dates && t.release_dates.results) || []; const m = rd.find((x) => x.iso_3166_1 === region) || rd[0];
|
| 74 |
+
return m ? ((m.release_dates || []).map((x) => x.certification).find(Boolean) || "") : "";
|
| 75 |
+
}
|
| 76 |
+
function logoOf(t) {
|
| 77 |
+
const lg = (t.images && t.images.logos) || [];
|
| 78 |
+
if (lg.length) { const en = lg.find((x) => x.iso_639_1 === "en" && /\.png$/i.test(x.file_path)) || lg.find((x) => x.iso_639_1 === "en") || lg[0]; return posterURL(en.file_path, "w300"); }
|
| 79 |
+
return t.logo_path ? posterURL(t.logo_path, "w300") : null;
|
| 80 |
+
}
|
| 81 |
+
function keywordsOf(t) { const k = (t.keywords && (t.keywords.keywords || t.keywords.results)) || []; return k.slice(0, 10).map((x) => x.name); }
|
| 82 |
+
function imdbOf(t) { return (t.external_ids && t.external_ids.imdb_id) || t.imdb_id || null; }
|
| 83 |
+
// a lightweight item for "More like this" (no recursive append extraction).
|
| 84 |
+
function liteItem(x) {
|
| 85 |
+
const isTv = !!(x.first_air_date) || (x.name && !x.title) || x.media_type === "tv";
|
| 86 |
+
const names = genreNames(x);
|
| 87 |
+
return { id: "tmdb:" + (isTv ? "series" : "movie") + ":" + x.id, tmdbId: x.id, kind: isTv ? "series" : "movie",
|
| 88 |
+
name: x.title || x.name || "Untitled", year: yearOf(x.release_date || x.first_air_date),
|
| 89 |
+
posterUrl: posterURL(x.poster_path), backdrop: backdropURL(x.backdrop_path), rating: typeof x.vote_average === "number" ? x.vote_average : null,
|
| 90 |
+
topics: topicsOf(names), genres: names, channel: "TheMovieDB", quality: x.vote_average ? Math.min(1, x.vote_average / 10) : 0.7,
|
| 91 |
+
source: "tmdb", provider: "tmdb", kappa: "", holoKappa: "tmdb:" + (isTv ? "series" : "movie") + ":" + x.id, availability: browseOnly() };
|
| 92 |
+
}
|
| 93 |
+
function recsOf(t) {
|
| 94 |
+
const all = [...(((t.recommendations && t.recommendations.results) || [])), ...(((t.similar && t.similar.results) || []))];
|
| 95 |
+
const seen = new Set(), out = [];
|
| 96 |
+
for (const x of all) { const it = liteItem(x); if (seen.has(it.id)) continue; seen.add(it.id); out.push(it); if (out.length >= 12) break; }
|
| 97 |
+
return out;
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
// ββ normalizers β every one returns the canonical shape ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 101 |
+
export function normalizeMovie(t, { availability, region } = {}) {
|
| 102 |
+
const names = genreNames(t);
|
| 103 |
+
const cd = castOf(t);
|
| 104 |
+
const imdb = imdbOf(t);
|
| 105 |
+
return {
|
| 106 |
+
id: "tmdb:movie:" + t.id, tmdbId: t.id, kind: "movie", name: t.title || t.name || "Untitled",
|
| 107 |
+
year: yearOf(t.release_date), releaseDate: t.release_date || "",
|
| 108 |
+
overview: t.overview || "", blurb: t.overview || "", tagline: t.tagline || "",
|
| 109 |
+
genres: names, topics: topicsOf(names),
|
| 110 |
+
cast: cd.length ? cd.map((c) => c.name) : (t.cast || []).slice(0, 8), castDetail: cd, directors: crewOf(t, "movie"),
|
| 111 |
+
rating: typeof t.vote_average === "number" ? t.vote_average : null, certification: certOf(t, "movie", region),
|
| 112 |
+
keywords: keywordsOf(t), recommendations: recsOf(t), imdbId: imdb,
|
| 113 |
+
runtimeSec: t.runtime ? t.runtime * 60 : 0,
|
| 114 |
+
posterUrl: posterURL(t.poster_path) || metahubPoster(imdb), backdrop: backdropURL(t.backdrop_path) || metahubBackdrop(imdb), logoUrl: logoOf(t),
|
| 115 |
+
trailerKey: trailerOf(t),
|
| 116 |
+
providers: t.providers || [],
|
| 117 |
+
channel: "TheMovieDB", quality: t.vote_average ? Math.min(1, t.vote_average / 10) : 0.7, license: "",
|
| 118 |
+
source: "tmdb", provider: "tmdb", kappa: "", holoKappa: "tmdb:movie:" + t.id,
|
| 119 |
+
availability: availability || browseOnly(),
|
| 120 |
+
};
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
export function normalizeSeries(t, { availability, region } = {}) {
|
| 124 |
+
const names = genreNames(t);
|
| 125 |
+
const imdb = imdbOf(t);
|
| 126 |
+
return {
|
| 127 |
+
id: "tmdb:series:" + t.id, tmdbId: t.id, kind: "series", name: t.name || t.title || "Untitled",
|
| 128 |
+
year: yearOf(t.first_air_date), releaseDate: t.first_air_date || "",
|
| 129 |
+
overview: t.overview || "", blurb: t.overview || "", tagline: t.tagline || "",
|
| 130 |
+
genres: names, topics: topicsOf(names),
|
| 131 |
+
cast: (() => { const cd = castOf(t); return cd.length ? cd.map((c) => c.name) : (t.cast || []).slice(0, 8); })(), castDetail: castOf(t), creators: crewOf(t, "series"),
|
| 132 |
+
rating: typeof t.vote_average === "number" ? t.vote_average : null, certification: certOf(t, "series", region),
|
| 133 |
+
keywords: keywordsOf(t), recommendations: recsOf(t), imdbId: imdb, networks: (t.networks || []).map((n) => n.name),
|
| 134 |
+
seasonCount: t.number_of_seasons || (Array.isArray(t.seasons) ? t.seasons.filter((s) => s.season_number > 0).length : 0),
|
| 135 |
+
episodeCount: t.number_of_episodes || 0,
|
| 136 |
+
seasons: Array.isArray(t.seasons)
|
| 137 |
+
? t.seasons.filter((s) => s.season_number != null).map((s) => ({
|
| 138 |
+
id: "tmdb:season:" + t.id + ":" + s.season_number, seriesId: "tmdb:series:" + t.id, seasonNumber: s.season_number,
|
| 139 |
+
name: s.name || "Season " + s.season_number, episodeCount: s.episode_count || 0,
|
| 140 |
+
posterUrl: posterURL(s.poster_path), overview: s.overview || "",
|
| 141 |
+
}))
|
| 142 |
+
: [],
|
| 143 |
+
runtimeSec: 0,
|
| 144 |
+
posterUrl: posterURL(t.poster_path) || metahubPoster(imdb), backdrop: backdropURL(t.backdrop_path) || metahubBackdrop(imdb), logoUrl: logoOf(t),
|
| 145 |
+
trailerKey: trailerOf(t),
|
| 146 |
+
providers: t.providers || [],
|
| 147 |
+
channel: "TheMovieDB", quality: t.vote_average ? Math.min(1, t.vote_average / 10) : 0.7, license: "",
|
| 148 |
+
source: "tmdb", provider: "tmdb", kappa: "", holoKappa: "tmdb:series:" + t.id,
|
| 149 |
+
availability: availability || browseOnly(),
|
| 150 |
+
};
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
export function normalizeEpisode(ep, series, { availability } = {}) {
|
| 154 |
+
const sid = "tmdb:series:" + (series.tmdbId ?? series.id);
|
| 155 |
+
return {
|
| 156 |
+
id: "tmdb:ep:" + (series.tmdbId ?? series.id) + ":" + ep.season_number + ":" + ep.episode_number,
|
| 157 |
+
tmdbId: ep.id, kind: "episode",
|
| 158 |
+
name: ep.name || `Episode ${ep.episode_number}`,
|
| 159 |
+
seriesId: sid, seriesName: series.name, parentId: "tmdb:season:" + (series.tmdbId ?? series.id) + ":" + ep.season_number,
|
| 160 |
+
seasonNumber: ep.season_number, episodeNumber: ep.episode_number,
|
| 161 |
+
overview: ep.overview || "", blurb: ep.overview || "",
|
| 162 |
+
rating: typeof ep.vote_average === "number" ? ep.vote_average : null,
|
| 163 |
+
runtimeSec: ep.runtime ? ep.runtime * 60 : 0, releaseDate: ep.air_date || "",
|
| 164 |
+
posterUrl: backdropURL(ep.still_path, "w300") || series.posterUrl, backdrop: series.backdrop,
|
| 165 |
+
topics: series.topics || [], genres: series.genres || [],
|
| 166 |
+
channel: "TheMovieDB", quality: series.quality ?? 0.7, license: "",
|
| 167 |
+
source: "tmdb", provider: "tmdb", kappa: "", holoKappa: "tmdb:ep:" + (series.tmdbId ?? series.id) + ":" + ep.season_number + ":" + ep.episode_number,
|
| 168 |
+
availability: availability || browseOnly(),
|
| 169 |
+
};
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
// TMDb /watch/providers β a flat, region-stamped list. Honest: provider availability is region- AND
|
| 173 |
+
// time-variant, so the caller stamps the fetch time; we stamp the region here.
|
| 174 |
+
export function normalizeWatchProviders(payload, region = "US") {
|
| 175 |
+
const r = payload && payload.results && payload.results[region];
|
| 176 |
+
if (!r) return [];
|
| 177 |
+
const out = [];
|
| 178 |
+
for (const type of ["flatrate", "free", "ads", "rent", "buy"]) {
|
| 179 |
+
for (const p of r[type] || []) out.push({ name: p.provider_name, type, region, logo: posterURL(p.logo_path, "w92"), link: r.link || "" });
|
| 180 |
+
}
|
| 181 |
+
// de-dup by provider name keeping the best (flatrate/free) tier
|
| 182 |
+
const seen = new Map();
|
| 183 |
+
for (const p of out) if (!seen.has(p.name)) seen.set(p.name, p);
|
| 184 |
+
return [...seen.values()];
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
// generic dispatch for a /trending or /search/multi mixed list
|
| 188 |
+
export function normalizeAny(t, opts) {
|
| 189 |
+
const mt = t.media_type || (t.title ? "movie" : t.name && t.first_air_date !== undefined ? "tv" : t.title ? "movie" : "tv");
|
| 190 |
+
if (mt === "tv" || t.first_air_date != null || t.number_of_seasons != null) return normalizeSeries(t, opts);
|
| 191 |
+
return normalizeMovie(t, opts);
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
// "Where to watch" β a "go watch it there" link. TMDb gives platform NAMES + ONE aggregate JustWatch link per
|
| 195 |
+
// title (no per-provider deep-links), so we open each platform's SEARCH for the title β the honest take-me-there.
|
| 196 |
+
// Unknown platform β the title's JustWatch link, else a web search. Makes the where-to-watch chips ACTIONABLE.
|
| 197 |
+
const WATCH_URLS = {
|
| 198 |
+
"netflix": (q) => "https://www.netflix.com/search?q=" + q,
|
| 199 |
+
"disney plus": (q) => "https://www.disneyplus.com/search?q=" + q,
|
| 200 |
+
"hulu": (q) => "https://www.hulu.com/search?q=" + q,
|
| 201 |
+
"amazon prime video": (q) => "https://www.amazon.com/s?k=" + q + "&i=instant-video",
|
| 202 |
+
"amazon video": (q) => "https://www.amazon.com/s?k=" + q + "&i=instant-video",
|
| 203 |
+
"max": (q) => "https://play.max.com/search?q=" + q,
|
| 204 |
+
"hbo max": (q) => "https://play.max.com/search?q=" + q,
|
| 205 |
+
"apple tv": (q) => "https://tv.apple.com/search?term=" + q,
|
| 206 |
+
"apple tv plus": (q) => "https://tv.apple.com/search?term=" + q,
|
| 207 |
+
"youtube": (q) => "https://www.youtube.com/results?search_query=" + q,
|
| 208 |
+
"peacock": (q) => "https://www.peacocktv.com/search?q=" + q,
|
| 209 |
+
"paramount plus": (q) => "https://www.paramountplus.com/search/?query=" + q,
|
| 210 |
+
"paramount+": (q) => "https://www.paramountplus.com/search/?query=" + q,
|
| 211 |
+
"crunchyroll": (q) => "https://www.crunchyroll.com/search?q=" + q,
|
| 212 |
+
};
|
| 213 |
+
export function watchLink(providerName, item, fallbackLink) {
|
| 214 |
+
const q = encodeURIComponent((item && item.name) || "");
|
| 215 |
+
const key = String(providerName || "").toLowerCase().replace(/\s+with\s+ads$/, "").trim();
|
| 216 |
+
const fn = WATCH_URLS[key];
|
| 217 |
+
if (fn) return fn(q);
|
| 218 |
+
if (fallbackLink) return fallbackLink; // TMDb's aggregate JustWatch link for the title
|
| 219 |
+
return "https://www.google.com/search?q=" + q + "%20watch%20online"; // last resort
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
export default { normalizeMovie, normalizeSeries, normalizeEpisode, normalizeWatchProviders, normalizeAny, browseOnly, posterURL, backdropURL, watchLink, TMDB_GENRES, TMDB_IMG };
|
b/52cf82d28c69b7ff9b5ae3df553c65957cf10295013dd6837a1c78e427874ef5
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"when": "2026-07-11T08:29:23.457Z",
|
| 3 |
+
"pass": true,
|
| 4 |
+
"checks": {
|
| 5 |
+
"loadsUniverse": true,
|
| 6 |
+
"verifyRefuse": true,
|
| 7 |
+
"worksOffline": true,
|
| 8 |
+
"facetAlgebra": true,
|
| 9 |
+
"pivotUnderFrame": true,
|
| 10 |
+
"searchFast": true,
|
| 11 |
+
"personPivot": true
|
| 12 |
+
},
|
| 13 |
+
"fail": [],
|
| 14 |
+
"p95ms": 1.91,
|
| 15 |
+
"searchMs": 1.89
|
| 16 |
+
}
|
b/5f654b793ccf0c8d9b4e6bc530dd15f7a032fd440f7bb353496cbf6692b7374f
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"$comment": "Holo TV Library root β the ΞΊ-addressed universe catalog. Each shard/persons object lives on the ΞΊ-mirror at b/<blake3>; sha256 is the verify key (SubtleCrypto at runtime, verify-or-refuse). Rows are compact: i/k/t/y/dec/g[]/m[](moods)/r/v/p/b/rt/imdb/col/c3/dir. Mood taxonomy is a fixed rule table in holo-library-forge.mjs.",
|
| 3 |
+
"version": 1,
|
| 4 |
+
"moodTaxonomyVersion": 1,
|
| 5 |
+
"generatedAt": "2026-07-11",
|
| 6 |
+
"counts": {
|
| 7 |
+
"titles": 18712,
|
| 8 |
+
"movies": 14474,
|
| 9 |
+
"series": 4238,
|
| 10 |
+
"persons": 1856
|
| 11 |
+
},
|
| 12 |
+
"mirror": "https://huggingface.co/HOLOGRAMTECH/holo-messenger-shell/resolve/main/b/",
|
| 13 |
+
"moods": [
|
| 14 |
+
"feelgood",
|
| 15 |
+
"dark",
|
| 16 |
+
"mindbending",
|
| 17 |
+
"cozy",
|
| 18 |
+
"adrenaline",
|
| 19 |
+
"tearjerker",
|
| 20 |
+
"scary",
|
| 21 |
+
"romantic",
|
| 22 |
+
"epic",
|
| 23 |
+
"quirky",
|
| 24 |
+
"truestory",
|
| 25 |
+
"family"
|
| 26 |
+
],
|
| 27 |
+
"shards": [
|
| 28 |
+
{
|
| 29 |
+
"sha256": "899d357cb5004de5addb181458ec52a4e282fabfd8cebb62ccf589a8ef1d8dcf",
|
| 30 |
+
"blake3": "57268b6ebc99004d38c72b7f966ccd8dd8fe3cccb5dd0f62a3d9a15ac1cdd150",
|
| 31 |
+
"bytes": 267733
|
| 32 |
+
},
|
| 33 |
+
{
|
| 34 |
+
"sha256": "e3fa688522ea3ce62d92cd6f4673729dce85883952d4dd9f5b6de8dfa6d0a20f",
|
| 35 |
+
"blake3": "6a6a84772313bcfd73301c11c78754cf1d685b1546d7d0420f30b12423b0390b",
|
| 36 |
+
"bytes": 273045
|
| 37 |
+
},
|
| 38 |
+
{
|
| 39 |
+
"sha256": "b9f4f511bbe1e52f388ee5c2a1cdb54783cd10661b25516f3bdf4eec2c3604b2",
|
| 40 |
+
"blake3": "a37412bc4a78d83579e7dd734d4d37790a8093db4c0c36926162c4a79be68361",
|
| 41 |
+
"bytes": 261298
|
| 42 |
+
},
|
| 43 |
+
{
|
| 44 |
+
"sha256": "fcdec94954cf10119e6eebf88986a59911894c862528953f64c82721fcb55adb",
|
| 45 |
+
"blake3": "15ed6838730d14422d136357d4a4c2f23decb4cc4b8ff37d3043022252fedad7",
|
| 46 |
+
"bytes": 269554
|
| 47 |
+
},
|
| 48 |
+
{
|
| 49 |
+
"sha256": "1c0d04b28758892b8df09f004c00247f14b6005dd22d7cddcc0c88aad7a0ec3c",
|
| 50 |
+
"blake3": "b077e38fe5b37f0ca78f07e03fb4753e820d0e5bf176b5c9a1d449c3c7e1ecda",
|
| 51 |
+
"bytes": 257809
|
| 52 |
+
},
|
| 53 |
+
{
|
| 54 |
+
"sha256": "0ecb222e1fc41a7bfc42f0840eafc951405c4ebb923a8e075d5f9a8e23994b1c",
|
| 55 |
+
"blake3": "e108d9fa3b23f072cd603b083e689212a17efb410cf5ca44e83031a8f35315fc",
|
| 56 |
+
"bytes": 271457
|
| 57 |
+
},
|
| 58 |
+
{
|
| 59 |
+
"sha256": "a9359b860826572f5ca186162152869ff62a674988a040bfcd820937e617a8cc",
|
| 60 |
+
"blake3": "8db5d6e27b8bca40c7d09a8747f8ab10b48912a68408ada34e1d019eda2bdb99",
|
| 61 |
+
"bytes": 265943
|
| 62 |
+
},
|
| 63 |
+
{
|
| 64 |
+
"sha256": "307563503bdd8834ecab0d9b41c99e864f79ccb78e8603623361a306cdfa40c0",
|
| 65 |
+
"blake3": "285af202571902c3d5a6ef284ea419ecc37479eb1cdd5bfc8e616b48bec8f3ef",
|
| 66 |
+
"bytes": 249973
|
| 67 |
+
},
|
| 68 |
+
{
|
| 69 |
+
"sha256": "478f1a92f61e4807bab0c11b8f20b39cee74027287674ef11757187f5debeba1",
|
| 70 |
+
"blake3": "9cc700200ebfaf0c7c0bab7070de7c704122d4a7a2eac1f99be1ba25aa76958a",
|
| 71 |
+
"bytes": 255471
|
| 72 |
+
},
|
| 73 |
+
{
|
| 74 |
+
"sha256": "8d830344e9ace494855c063f44407868a21ccd0da178dfdac22fb17cf5b7e5ac",
|
| 75 |
+
"blake3": "bc51eb2b230db9ebe767cb0583c593e13af1a9bacb8bda30d189ec0c960413de",
|
| 76 |
+
"bytes": 259790
|
| 77 |
+
},
|
| 78 |
+
{
|
| 79 |
+
"sha256": "f05ee9415de028e9dba4de0d007a22ddfb249ea6a8af8580cb59cbe312d2772b",
|
| 80 |
+
"blake3": "31be625986b020bca96ecd931d97d67db93c003c5f93a7cf452ba6b4f4c290f3",
|
| 81 |
+
"bytes": 261008
|
| 82 |
+
},
|
| 83 |
+
{
|
| 84 |
+
"sha256": "e06268932c02d558fad1d36ce1327317190dbba086e5f8b74a9fa9fa5ef7307b",
|
| 85 |
+
"blake3": "7ef6dfe21b07713cc73e5188df264e5465de9ad49f93c07b23adda17c4779e40",
|
| 86 |
+
"bytes": 276469
|
| 87 |
+
},
|
| 88 |
+
{
|
| 89 |
+
"sha256": "622bf2bdc0dabd2ef04943988fa4f4a1c0b56e80d5cb56684528135cb53fb823",
|
| 90 |
+
"blake3": "3d5a39676cce4996cb7cac51d09f6c76ba4a9934033b950046be2ca687ba2738",
|
| 91 |
+
"bytes": 278653
|
| 92 |
+
},
|
| 93 |
+
{
|
| 94 |
+
"sha256": "062cf09c49de97e943a4973030d9e75cd56a2dac457dcc65fb470950773585da",
|
| 95 |
+
"blake3": "873b3e87caf5b6641a890d7e21f11d7afd44f3a0b4ab2868e1c6734bc3ec87ad",
|
| 96 |
+
"bytes": 255464
|
| 97 |
+
},
|
| 98 |
+
{
|
| 99 |
+
"sha256": "85a3270652ccaa4aa69e972da9be0f7dfab00280f96d2d7e10f0f97aabc7fb9b",
|
| 100 |
+
"blake3": "1f2875d3d4608a221fecb6624b18f79e9caf3283ecf0946f0524e8328045fa85",
|
| 101 |
+
"bytes": 255963
|
| 102 |
+
},
|
| 103 |
+
{
|
| 104 |
+
"sha256": "493b149742c721385f4d2aecec8c2196945bc6aed3df7535664c830823cc27ac",
|
| 105 |
+
"blake3": "e2b5fcf26615db3e673cf1e4c365e916d7a687f0016b5d9d457c25eee2478a39",
|
| 106 |
+
"bytes": 261255
|
| 107 |
+
}
|
| 108 |
+
],
|
| 109 |
+
"persons": {
|
| 110 |
+
"sha256": "5d86ef6c1b5ec78c7e0ddd11a8d3ada0b24d6a879f2e7e5567b8a6dcb28a7531",
|
| 111 |
+
"blake3": "3c6d39d693bc0cb5e46d572037269e20b6379985350e4d72bb6fbfdf2831670e",
|
| 112 |
+
"bytes": 179005
|
| 113 |
+
}
|
| 114 |
+
}
|
b/a43249f4f27206cc010c369c220b1469ee0564c4d75f798d63d2c18425d063be
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
// holo-library-forge.mjs β THE FORGE: the movie/TV universe β ΞΊ (HOLO-TV-LIBRARY-PROMPT.md L1).
|
| 3 |
+
//
|
| 4 |
+
// Sweeps TMDb /discover by year Γ kind (vote floors keep it real titles, not noise), enriches the head
|
| 5 |
+
// of the popularity curve with one append_to_response call each (runtime Β· imdb Β· collection Β· top cast Β·
|
| 6 |
+
// director Β· keywordsβmoods), and mints the whole catalog as CONTENT-ADDRESSED shards:
|
| 7 |
+
//
|
| 8 |
+
// rows β 16 shards (hash(id)%16), each a raw-JSON ΞΊ-object (sha256 of bytes = verify key,
|
| 9 |
+
// blake3 of bytes = the ΞΊ-mirror path b/<hex>)
|
| 10 |
+
// persons β ONE inverse posting object (person β their title ids; persons with β₯3 titles)
|
| 11 |
+
// root β feed/library-index.json (tiny, published in the Q repo): shard ΞΊs + counts + taxonomy
|
| 12 |
+
//
|
| 13 |
+
// Deterministic: same input β same rows β same ΞΊs (rows sorted by id; JSON.stringify of plain objects
|
| 14 |
+
// with fixed key order; undefined fields OMITTED β the RFC-8785 lesson). Moods are a FIXED rule table
|
| 15 |
+
// (versioned below), never a model call. Resumable: every network page lands in a checkpoint file first.
|
| 16 |
+
//
|
| 17 |
+
// node holo-library-forge.mjs sweep + enrich + mint + UPLOAD + write root
|
| 18 |
+
// node holo-library-forge.mjs --dry sweep + mint locally, print ΞΊs + sizes, no upload/root
|
| 19 |
+
// node holo-library-forge.mjs --no-net re-mint from the checkpoint only (deterministic re-derive)
|
| 20 |
+
|
| 21 |
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
| 22 |
+
import { createHash } from "node:crypto";
|
| 23 |
+
import os from "node:os";
|
| 24 |
+
import path from "node:path";
|
| 25 |
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
| 26 |
+
|
| 27 |
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
| 28 |
+
const HOLOGRAM = path.resolve(HERE, "../../..");
|
| 29 |
+
const { blake3hex } = await import(pathToFileURL(path.join(HOLOGRAM, "holo-os/system/os/usr/lib/holo/holo-blake3.mjs")).href);
|
| 30 |
+
|
| 31 |
+
const DRY = process.argv.includes("--dry");
|
| 32 |
+
const NO_NET = process.argv.includes("--no-net");
|
| 33 |
+
const KEY = "45f791c5f6a6940a50403bb3890c7b86"; // the built-in community key (holo-tmdb.mjs)
|
| 34 |
+
const API = "https://api.themoviedb.org/3";
|
| 35 |
+
const CKPT = path.join(HERE, ".library-forge-checkpoint.json");
|
| 36 |
+
const SHARDS = 16;
|
| 37 |
+
const YEARS = { movie: [1940, new Date().getFullYear()], tv: [1950, new Date().getFullYear()] };
|
| 38 |
+
const FLOOR = { movie: 200, tv: 100 }; // vote_count floors (L0-probed: β19k titles)
|
| 39 |
+
const ENRICH_TOP = 5000; // head of the curve gets runtime/cast/keywords
|
| 40 |
+
const MIRROR = "https://huggingface.co/HOLOGRAMTECH/holo-messenger-shell/resolve/main/b/";
|
| 41 |
+
const HF_REPO = "HOLOGRAMTECH/holo-messenger-shell";
|
| 42 |
+
|
| 43 |
+
// ββ mood taxonomy v1 β FIXED deterministic rules (genres βͺ keyword clusters); versioned, never a model ββ
|
| 44 |
+
export const MOOD_TAXONOMY_VERSION = 1;
|
| 45 |
+
const KW = {
|
| 46 |
+
mindbending: ["time travel", "time loop", "parallel", "memory", "dream", "simulation", "plot twist", "identity", "surreal", "nonlinear"],
|
| 47 |
+
tearjerker: ["grief", "terminal illness", "cancer", "tragedy", "loss of", "death of", "holocaust", "euthanasia"],
|
| 48 |
+
quirky: ["surreal", "absurd", "black comedy", "dark comedy", "satire", "offbeat", "deadpan", "mockumentary"],
|
| 49 |
+
truestory: ["based on true story", "based on a true story", "biography", "based on real events", "true events", "biopic"],
|
| 50 |
+
};
|
| 51 |
+
const kwHit = (kws, list) => list.some((n) => kws.some((k) => k.includes(n)));
|
| 52 |
+
// g = tmdb genre ids (movie+tv unioned); rt minutes|0; r rating; v votes; kws lowercase keyword names
|
| 53 |
+
export function moodsOf({ g = [], rt = 0, r = 0, v = 0, kws = [] }) {
|
| 54 |
+
const has = (...ids) => ids.some((x) => g.includes(x));
|
| 55 |
+
const m = [];
|
| 56 |
+
if (has(35, 10402, 10751) && r >= 6.5) m.push("feelgood");
|
| 57 |
+
if (has(80, 53, 9648) && !has(10751, 16) ) m.push("dark");
|
| 58 |
+
if ((has(878, 10765) && has(9648)) || kwHit(kws, KW.mindbending)) m.push("mindbending");
|
| 59 |
+
if (has(10751, 16, 10749) && (!rt || rt <= 115) && r >= 6) m.push("cozy");
|
| 60 |
+
if (has(28, 53, 10759)) m.push("adrenaline");
|
| 61 |
+
if (has(18) && (kwHit(kws, KW.tearjerker) || (has(10749) && r >= 7.2))) m.push("tearjerker");
|
| 62 |
+
if (has(27)) m.push("scary");
|
| 63 |
+
if (has(10749)) m.push("romantic");
|
| 64 |
+
if (has(12, 10752, 36, 14, 10765, 10768) && ((rt >= 140) || v >= 5000)) m.push("epic");
|
| 65 |
+
if (has(35) && kwHit(kws, KW.quirky)) m.push("quirky");
|
| 66 |
+
if (kwHit(kws, KW.truestory) || has(36, 99)) m.push("truestory");
|
| 67 |
+
if (has(10751, 16, 10762)) m.push("family");
|
| 68 |
+
return m;
|
| 69 |
+
}
|
| 70 |
+
export const MOODS = ["feelgood", "dark", "mindbending", "cozy", "adrenaline", "tearjerker", "scary", "romantic", "epic", "quirky", "truestory", "family"];
|
| 71 |
+
|
| 72 |
+
// ββ polite, resumable TMDb βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 73 |
+
let inflight = 0, lastTick = 0;
|
| 74 |
+
async function tmdb(p, params = {}) {
|
| 75 |
+
const u = new URL(API + p); u.searchParams.set("api_key", KEY);
|
| 76 |
+
for (const [k, v] of Object.entries(params)) u.searchParams.set(k, String(v));
|
| 77 |
+
for (let attempt = 0; ; attempt++) {
|
| 78 |
+
const wait = Math.max(0, lastTick + 34 - Date.now()); // β29 req/s ceiling
|
| 79 |
+
lastTick = Math.max(Date.now(), lastTick + 34);
|
| 80 |
+
if (wait) await new Promise((r) => setTimeout(r, wait));
|
| 81 |
+
const res = await fetch(u).catch(() => null);
|
| 82 |
+
if (res && res.status === 429) { await new Promise((r) => setTimeout(r, 1500 * (attempt + 1))); continue; }
|
| 83 |
+
if (!res || !res.ok) { if (attempt >= 3) throw new Error("tmdb " + (res ? res.status : "net") + " " + p); await new Promise((r) => setTimeout(r, 800 * (attempt + 1))); continue; }
|
| 84 |
+
return res.json();
|
| 85 |
+
}
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
// ββ the sweep (checkpointed) βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 89 |
+
const ckpt = existsSync(CKPT) ? JSON.parse(readFileSync(CKPT, "utf8")) : { discover: {}, enrich: {} };
|
| 90 |
+
const saveCkpt = () => writeFileSync(CKPT, JSON.stringify(ckpt));
|
| 91 |
+
|
| 92 |
+
async function sweep() {
|
| 93 |
+
for (const kind of ["movie", "tv"]) {
|
| 94 |
+
const [y0, y1] = YEARS[kind];
|
| 95 |
+
for (let y = y0; y <= y1; y++) {
|
| 96 |
+
const ck = kind + ":" + y;
|
| 97 |
+
if (ckpt.discover[ck]) continue;
|
| 98 |
+
const yearParam = kind === "movie" ? { primary_release_year: y } : { first_air_date_year: y };
|
| 99 |
+
const base = { ...yearParam, "vote_count.gte": FLOOR[kind], sort_by: "vote_count.desc", include_adult: false };
|
| 100 |
+
const first = await tmdb(`/discover/${kind}`, { ...base, page: 1 });
|
| 101 |
+
const pages = Math.min(first.total_pages || 1, 500);
|
| 102 |
+
const rows = [...(first.results || [])];
|
| 103 |
+
for (let pg = 2; pg <= pages; pg++) rows.push(...((await tmdb(`/discover/${kind}`, { ...base, page: pg })).results || []));
|
| 104 |
+
ckpt.discover[ck] = rows.map((t) => ({
|
| 105 |
+
id: t.id, kind, title: kind === "movie" ? t.title : t.name,
|
| 106 |
+
year: parseInt((kind === "movie" ? t.release_date : t.first_air_date) || "0") || 0,
|
| 107 |
+
g: t.genre_ids || [], r: Math.round((t.vote_average || 0) * 10) / 10, v: t.vote_count || 0,
|
| 108 |
+
p: t.poster_path || null, b: t.backdrop_path || null, l: t.original_language || "",
|
| 109 |
+
}));
|
| 110 |
+
saveCkpt();
|
| 111 |
+
if (y % 10 === 0) console.log(`sweep ${ck}: ${rows.length} titles`);
|
| 112 |
+
}
|
| 113 |
+
}
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
async function enrich(all) {
|
| 117 |
+
const head = [...all].sort((a, b) => b.v - a.v).slice(0, ENRICH_TOP);
|
| 118 |
+
let done = 0;
|
| 119 |
+
for (const t of head) {
|
| 120 |
+
const ck = t.kind + ":" + t.id;
|
| 121 |
+
if (!(ck in ckpt.enrich)) {
|
| 122 |
+
try {
|
| 123 |
+
const d = await tmdb(`/${t.kind}/${t.id}`, { append_to_response: "keywords,credits,external_ids" });
|
| 124 |
+
const kws = ((d.keywords && (d.keywords.keywords || d.keywords.results)) || []).map((k) => (k.name || "").toLowerCase());
|
| 125 |
+
const cast = ((d.credits && d.credits.cast) || []).slice(0, 3).map((c) => [c.id, c.name]);
|
| 126 |
+
const dir = ((d.credits && d.credits.crew) || []).find((c) => c.job === "Director" || c.job === "Creator");
|
| 127 |
+
ckpt.enrich[ck] = {
|
| 128 |
+
rt: d.runtime || (Array.isArray(d.episode_run_time) && d.episode_run_time[0]) || 0,
|
| 129 |
+
imdb: (d.external_ids && d.external_ids.imdb_id) || d.imdb_id || null,
|
| 130 |
+
col: (d.belongs_to_collection && d.belongs_to_collection.id) || null,
|
| 131 |
+
colName: (d.belongs_to_collection && d.belongs_to_collection.name) || null,
|
| 132 |
+
c3: cast.length ? cast : null, dir: dir ? [dir.id, dir.name] : null, kws,
|
| 133 |
+
seasons: d.number_of_seasons || null,
|
| 134 |
+
};
|
| 135 |
+
} catch { ckpt.enrich[ck] = {}; }
|
| 136 |
+
if (++done % 250 === 0) { saveCkpt(); console.log(`enrich ${done}/${head.length}`); }
|
| 137 |
+
}
|
| 138 |
+
}
|
| 139 |
+
saveCkpt();
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
// ββ mint: rows β shards β ΞΊ ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 143 |
+
const sha256hex = (buf) => createHash("sha256").update(buf).digest("hex");
|
| 144 |
+
function mint() {
|
| 145 |
+
const seen = new Set(); const all = [];
|
| 146 |
+
for (const rows of Object.values(ckpt.discover)) for (const t of rows) {
|
| 147 |
+
const k = t.kind + ":" + t.id;
|
| 148 |
+
if (seen.has(k) || !t.year || !t.p) continue; // no art or no year = not library-grade
|
| 149 |
+
seen.add(k); all.push(t);
|
| 150 |
+
}
|
| 151 |
+
const persons = new Map(); // id β { n: name, t: [rowKeyβ¦] }
|
| 152 |
+
const rows = all.map((t) => {
|
| 153 |
+
const e = ckpt.enrich[t.kind + ":" + t.id] || {};
|
| 154 |
+
const kws = e.kws || [];
|
| 155 |
+
const row = {
|
| 156 |
+
i: t.id, k: t.kind === "movie" ? "m" : "t", t: t.title, y: t.year, dec: Math.floor(t.year / 10) * 10,
|
| 157 |
+
g: t.g, m: moodsOf({ g: t.g, rt: e.rt || 0, r: t.r, v: t.v, kws }),
|
| 158 |
+
r: t.r, v: t.v, p: t.p, b: t.b || undefined, l: t.l || undefined,
|
| 159 |
+
rt: e.rt || undefined, imdb: e.imdb || undefined, col: e.col || undefined, cn: e.colName || undefined,
|
| 160 |
+
sn: e.seasons || undefined,
|
| 161 |
+
};
|
| 162 |
+
const key = row.k + row.i;
|
| 163 |
+
for (const pers of [...(e.c3 || []), ...(e.dir ? [e.dir] : [])]) {
|
| 164 |
+
const rec = persons.get(pers[0]) || { n: pers[1], t: [] };
|
| 165 |
+
rec.t.push(key); persons.set(pers[0], rec);
|
| 166 |
+
}
|
| 167 |
+
if (e.c3) row.c3 = e.c3.map((c) => c[0]);
|
| 168 |
+
if (e.dir) row.dir = e.dir[0];
|
| 169 |
+
return JSON.parse(JSON.stringify(row)); // strips undefined β ΞΊ-stable bytes
|
| 170 |
+
}).sort((a, b) => (a.k + a.i < b.k + b.i ? -1 : 1));
|
| 171 |
+
|
| 172 |
+
const shards = Array.from({ length: SHARDS }, () => []);
|
| 173 |
+
for (const r of rows) shards[(r.i + (r.k === "t" ? 7 : 0)) % SHARDS].push(r);
|
| 174 |
+
const personsObj = Object.fromEntries([...persons].filter(([, v]) => v.t.length >= 3).sort((a, b) => a[0] - b[0]));
|
| 175 |
+
|
| 176 |
+
const objects = shards.map((s, n) => ({ name: "shard" + n, bytes: Buffer.from(JSON.stringify({ v: 1, n, rows: s })) }));
|
| 177 |
+
objects.push({ name: "persons", bytes: Buffer.from(JSON.stringify({ v: 1, persons: personsObj })) });
|
| 178 |
+
for (const o of objects) { o.sha256 = sha256hex(o.bytes); o.blake3 = blake3hex(new Uint8Array(o.bytes)); }
|
| 179 |
+
return { rows, objects, personsCount: Object.keys(personsObj).length };
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
// ββ upload + root ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 183 |
+
async function upload(objects) {
|
| 184 |
+
const token = readFileSync(path.join(os.homedir(), ".cache/huggingface/token"), "utf8").trim();
|
| 185 |
+
const missing = [];
|
| 186 |
+
for (const o of objects) if (!(await fetch(MIRROR + o.blake3, { method: "HEAD" })).ok) missing.push(o);
|
| 187 |
+
console.log(`mirror: ${objects.length - missing.length} present, ${missing.length} to upload`);
|
| 188 |
+
if (missing.length) {
|
| 189 |
+
const ndjson = [JSON.stringify({ key: "header", value: { summary: `holo-library-forge: ${missing.length} catalog object(s)` } })]
|
| 190 |
+
.concat(missing.map((o) => JSON.stringify({ key: "file", value: { path: "b/" + o.blake3, content: o.bytes.toString("base64"), encoding: "base64" } })))
|
| 191 |
+
.join("\n");
|
| 192 |
+
const r = await fetch(`https://huggingface.co/api/models/${HF_REPO}/commit/main`, {
|
| 193 |
+
method: "POST", headers: { Authorization: "Bearer " + token, "Content-Type": "application/x-ndjson" }, body: ndjson,
|
| 194 |
+
});
|
| 195 |
+
if (!r.ok) throw new Error("HF commit failed: " + r.status + " " + (await r.text()).slice(0, 200));
|
| 196 |
+
console.log("HF commit β");
|
| 197 |
+
}
|
| 198 |
+
for (const o of objects) { // refuse-at-boundary: every object must re-derive from the mirror
|
| 199 |
+
const back = new Uint8Array(await (await fetch(MIRROR + o.blake3)).arrayBuffer());
|
| 200 |
+
if (blake3hex(back) !== o.blake3 || sha256hex(Buffer.from(back)) !== o.sha256) throw new Error("mirror does not re-derive: " + o.name);
|
| 201 |
+
}
|
| 202 |
+
console.log("mirror re-derives β all", objects.length);
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
// ββ run ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 206 |
+
if (!NO_NET) { await sweep(); }
|
| 207 |
+
const preAll = Object.values(ckpt.discover).flat();
|
| 208 |
+
if (!NO_NET) { await enrich(preAll.filter((t) => t.p && t.year)); }
|
| 209 |
+
const { rows, objects, personsCount } = mint();
|
| 210 |
+
const total = objects.reduce((s, o) => s + o.bytes.length, 0);
|
| 211 |
+
console.log(`minted ${rows.length} titles (${rows.filter((r) => r.k === "m").length} movies Β· ${rows.filter((r) => r.k === "t").length} series) Β· ${personsCount} persons Β· ${objects.length} objects Β· ${(total / 1048576).toFixed(1)} MB`);
|
| 212 |
+
for (const o of objects) console.log(` ${o.name}: ${(o.bytes.length / 1024).toFixed(0)} KB sha256:${o.sha256.slice(0, 12)}β¦ blake3:${o.blake3.slice(0, 12)}β¦`);
|
| 213 |
+
|
| 214 |
+
if (!DRY) {
|
| 215 |
+
await upload(objects);
|
| 216 |
+
const root = {
|
| 217 |
+
$comment: "Holo TV Library root β the ΞΊ-addressed universe catalog. Each shard/persons object lives on the ΞΊ-mirror at b/<blake3>; sha256 is the verify key (SubtleCrypto at runtime, verify-or-refuse). Rows are compact: i/k/t/y/dec/g[]/m[](moods)/r/v/p/b/rt/imdb/col/c3/dir. Mood taxonomy is a fixed rule table in holo-library-forge.mjs.",
|
| 218 |
+
version: 1, moodTaxonomyVersion: MOOD_TAXONOMY_VERSION, generatedAt: new Date().toISOString().slice(0, 10),
|
| 219 |
+
counts: { titles: rows.length, movies: rows.filter((r) => r.k === "m").length, series: rows.filter((r) => r.k === "t").length, persons: personsCount },
|
| 220 |
+
mirror: MIRROR, moods: MOODS,
|
| 221 |
+
shards: objects.filter((o) => o.name.startsWith("shard")).map((o) => ({ sha256: o.sha256, blake3: o.blake3, bytes: o.bytes.length })),
|
| 222 |
+
persons: (({ sha256, blake3, bytes }) => ({ sha256, blake3, bytes: bytes.length }))(objects.find((o) => o.name === "persons")),
|
| 223 |
+
};
|
| 224 |
+
writeFileSync(path.join(HERE, "feed/library-index.json"), JSON.stringify(root, null, 1) + "\n");
|
| 225 |
+
console.log("root written: feed/library-index.json β");
|
| 226 |
+
}
|
b/e8e3471220a882a17984b9b6efb6a498bb3c82fcdb7a3547dc3905eed732c2fe
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
// holo-library-witness.mjs β proves the Library plane: verify-or-refuse, offline, and under-a-frame pivots
|
| 3 |
+
// on a universe-scale synthetic catalog. Fake mirror + Map cache β no network, no key.
|
| 4 |
+
//
|
| 5 |
+
// Checks:
|
| 6 |
+
// 1 loadsUniverse β all shards load, rows concatenated, byKey resolves.
|
| 7 |
+
// 2 verifyRefuse β a TAMPERED cached shard is refused (re-fetched clean), a tampered MIRROR shard throws.
|
| 8 |
+
// 3 worksOffline β warm cache + dead network β full load still succeeds (0 fetches).
|
| 9 |
+
// 4 facetAlgebra β genre β© decade β© mood β© runtime compose correctly (hand-checked expectations).
|
| 10 |
+
// 5 pivotUnderFrame β 6-way facet pivot over 19,000 rows completes in < 16 ms (p95 of 50 runs).
|
| 11 |
+
// 6 searchFast β search-as-you-type over 19k titles < 30 ms, prefix beats substring.
|
| 12 |
+
// 7 personPivot β person posting resolves to their titles via rowByKey.
|
| 13 |
+
//
|
| 14 |
+
// node holo-library-witness.mjs (from holo-apps/apps/player/)
|
| 15 |
+
|
| 16 |
+
import { writeFileSync } from "node:fs";
|
| 17 |
+
import { createHash } from "node:crypto";
|
| 18 |
+
import { fileURLToPath } from "node:url";
|
| 19 |
+
import { dirname, join } from "node:path";
|
| 20 |
+
import { createLibrary } from "./holo-library.mjs";
|
| 21 |
+
|
| 22 |
+
const here = dirname(fileURLToPath(import.meta.url));
|
| 23 |
+
const checks = {}; const fail = [];
|
| 24 |
+
const ok = (n, c, d = "") => { checks[n] = !!c; if (!c) fail.push(n + (d ? ` β ${d}` : "")); return !!c; };
|
| 25 |
+
const sha256hex = async (u8) => createHash("sha256").update(u8).digest("hex");
|
| 26 |
+
|
| 27 |
+
// ββ synthetic universe: 19,000 deterministic rows across 16 shards + persons ββββββββββββββββββββββββββββ
|
| 28 |
+
const GENRES = [28, 35, 18, 27, 878, 10749, 80, 12, 16, 9648];
|
| 29 |
+
const MOODS = ["feelgood", "dark", "mindbending", "cozy", "adrenaline", "scary", "romantic", "epic"];
|
| 30 |
+
const N = 19000;
|
| 31 |
+
const mk = (i) => ({
|
| 32 |
+
i, k: i % 4 === 0 ? "t" : "m", t: "Title " + i + (i % 7 === 0 ? " Prefixable" : ""), y: 1940 + (i % 86),
|
| 33 |
+
dec: Math.floor((1940 + (i % 86)) / 10) * 10, g: [GENRES[i % 10], GENRES[(i + 3) % 10]],
|
| 34 |
+
m: [MOODS[i % 8]], r: 5 + (i % 50) / 10, v: 100 + (i % 9000), p: "/p" + i + ".jpg", rt: 60 + (i % 120),
|
| 35 |
+
});
|
| 36 |
+
const rows = Array.from({ length: N }, (_, i) => mk(i));
|
| 37 |
+
const shards = Array.from({ length: 16 }, () => []);
|
| 38 |
+
for (const r of rows) shards[(r.i + (r.k === "t" ? 7 : 0)) % 16].push(r);
|
| 39 |
+
const objects = shards.map((s, n) => Buffer.from(JSON.stringify({ v: 1, n, rows: s })));
|
| 40 |
+
const personsObj = { 7: { n: "Test Person", t: [rows[1].k + rows[1].i, rows[2].k + rows[2].i, rows[5].k + rows[5].i] } };
|
| 41 |
+
objects.push(Buffer.from(JSON.stringify({ v: 1, persons: personsObj })));
|
| 42 |
+
const refs = await Promise.all(objects.map(async (b) => ({ sha256: await sha256hex(b), blake3: "fake" + (await sha256hex(b)).slice(0, 8), bytes: b.length })));
|
| 43 |
+
const ROOT = { version: 1, mirror: "https://mirror.test/b/", shards: refs.slice(0, 16), persons: refs[16], counts: { titles: N } };
|
| 44 |
+
const byBlake = new Map(refs.map((r, idx) => [r.blake3, objects[idx]]));
|
| 45 |
+
|
| 46 |
+
let fetches = 0, netDown = false;
|
| 47 |
+
const fakeFetch = async (url) => {
|
| 48 |
+
if (netDown) throw new Error("network down");
|
| 49 |
+
fetches++;
|
| 50 |
+
const hex = String(url).split("/b/")[1];
|
| 51 |
+
const b = byBlake.get(hex);
|
| 52 |
+
return b ? { ok: true, arrayBuffer: async () => new Uint8Array(b).buffer } : { ok: false, status: 404 };
|
| 53 |
+
};
|
| 54 |
+
const kv = new Map();
|
| 55 |
+
const cacheGet = async (sha) => kv.get(sha) || null;
|
| 56 |
+
const cachePut = async (sha, bytes) => { kv.set(sha, bytes); };
|
| 57 |
+
|
| 58 |
+
// 1 loadsUniverse
|
| 59 |
+
const lib = createLibrary({ root: ROOT, fetch: fakeFetch, cacheGet, cachePut, sha256hex });
|
| 60 |
+
await lib.load();
|
| 61 |
+
ok("loadsUniverse", lib.rows.length === N && lib.rowByKey("m1") && lib.rowByKey("t4"), `rows=${lib.rows.length}`);
|
| 62 |
+
|
| 63 |
+
// 2 verifyRefuse β tamper a cache entry β refused + healed from mirror; tamper the mirror β throws
|
| 64 |
+
const someSha = ROOT.shards[3].sha256;
|
| 65 |
+
kv.set(someSha, new Uint8Array([1, 2, 3]));
|
| 66 |
+
const lib2 = createLibrary({ root: ROOT, fetch: fakeFetch, cacheGet, cachePut, sha256hex });
|
| 67 |
+
await lib2.load();
|
| 68 |
+
const healed = lib2.stats().refused >= 1 && lib2.rows.length === N && (await sha256hex(kv.get(someSha))) === someSha;
|
| 69 |
+
let mirrorRefused = false;
|
| 70 |
+
{
|
| 71 |
+
const badFetch = async () => ({ ok: true, arrayBuffer: async () => new Uint8Array([9, 9, 9]).buffer });
|
| 72 |
+
const lib3 = createLibrary({ root: ROOT, fetch: badFetch, cacheGet: async () => null, cachePut: null, sha256hex });
|
| 73 |
+
try { await lib3.load(); } catch (e) { mirrorRefused = /REFUSED/.test(String(e)); }
|
| 74 |
+
}
|
| 75 |
+
ok("verifyRefuse", healed && mirrorRefused, `healed=${healed} mirrorRefused=${mirrorRefused}`);
|
| 76 |
+
|
| 77 |
+
// 3 worksOffline β cache is warm from check 1; kill the network entirely
|
| 78 |
+
netDown = true; fetches = 0;
|
| 79 |
+
const lib4 = createLibrary({ root: ROOT, fetch: fakeFetch, cacheGet, cachePut, sha256hex });
|
| 80 |
+
await lib4.load();
|
| 81 |
+
ok("worksOffline", lib4.rows.length === N && fetches === 0, `rows=${lib4.rows.length} fetches=${fetches}`);
|
| 82 |
+
netDown = false;
|
| 83 |
+
|
| 84 |
+
// 4 facetAlgebra β hand-checked compositions
|
| 85 |
+
const q1 = lib.query({ genres: [35], decades: [1980] });
|
| 86 |
+
const truth1 = rows.filter((r) => r.g.includes(35) && r.dec === 1980).length;
|
| 87 |
+
const q2 = lib.query({ genres: [35], decades: [1980], moods: ["feelgood"], runtimeMax: 100 });
|
| 88 |
+
const truth2 = rows.filter((r) => r.g.includes(35) && r.dec === 1980 && r.m.includes("feelgood") && r.rt && r.rt <= 100).length;
|
| 89 |
+
const q3 = lib.query({ kind: "t", ratingMin: 8 });
|
| 90 |
+
const truth3 = rows.filter((r) => r.k === "t" && r.r >= 8).length;
|
| 91 |
+
ok("facetAlgebra", q1.length === truth1 && q2.length === truth2 && q3.length === truth3, `${q1.length}/${truth1} ${q2.length}/${truth2} ${q3.length}/${truth3}`);
|
| 92 |
+
|
| 93 |
+
// 5 pivotUnderFrame β p95 of 50 six-way pivots < 16ms
|
| 94 |
+
const times = [];
|
| 95 |
+
for (let i = 0; i < 50; i++) {
|
| 96 |
+
const t0 = performance.now();
|
| 97 |
+
lib.query({ kind: "m", genres: [GENRES[i % 10]], moods: [MOODS[i % 8]], decades: [1970 + (i % 5) * 10], runtimeMax: 150, ratingMin: 6, limit: 60 });
|
| 98 |
+
times.push(performance.now() - t0);
|
| 99 |
+
}
|
| 100 |
+
times.sort((a, b) => a - b);
|
| 101 |
+
const p95 = times[Math.floor(times.length * 0.95)];
|
| 102 |
+
ok("pivotUnderFrame", p95 < 16, `p95=${p95.toFixed(2)}ms`);
|
| 103 |
+
|
| 104 |
+
// 6 searchFast β prefix beats substring, < 30ms
|
| 105 |
+
const t0 = performance.now(); const sr = lib.search("title 7"); const searchMs = performance.now() - t0;
|
| 106 |
+
ok("searchFast", searchMs < 30 && sr.length > 0 && sr[0].t.toLowerCase().startsWith("title 7"), `ms=${searchMs.toFixed(2)} first=${sr[0] && sr[0].t}`);
|
| 107 |
+
|
| 108 |
+
// 7 personPivot
|
| 109 |
+
const p = await lib.person(7);
|
| 110 |
+
const titles = p ? p.t.map((k) => lib.rowByKey(k)).filter(Boolean) : [];
|
| 111 |
+
ok("personPivot", !!p && titles.length === 3 && p.n === "Test Person", JSON.stringify(p));
|
| 112 |
+
|
| 113 |
+
// ββ verdict βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 114 |
+
const pass = fail.length === 0;
|
| 115 |
+
console.log("\nholo-library witness β the ΞΊ-addressed universe plane: verified, offline, under-a-frame\n");
|
| 116 |
+
for (const [n, c] of Object.entries(checks)) console.log(` ${c ? "β" : "β"} ${n}`);
|
| 117 |
+
console.log(pass ? `\n WITNESSED β ${N} titles Β· pivot p95 ${p95.toFixed(1)}ms Β· search ${searchMs.toFixed(1)}ms Β· tamper refused Β· offline whole\n` : `\n FAILED: ${fail.join("; ")}\n`);
|
| 118 |
+
writeFileSync(join(here, "holo-library-witness.result.json"), JSON.stringify({ when: new Date().toISOString(), pass, checks, fail, p95ms: +p95.toFixed(2), searchMs: +searchMs.toFixed(2) }, null, 2));
|
| 119 |
+
process.exit(pass ? 0 : 1);
|