Humuhumu33 commited on
Commit
d9cc931
Β·
verified Β·
1 Parent(s): 3f54ef6

holo-evicted-publish: player +6 object(s)

Browse files
b/0e11652253d3fe00b1e9525ff834a95dd7bfca5ec4c1928c3291846142691371 ADDED
The diff for this file is too large to render. See raw diff
 
b/677b4ff637d9ccf19033203c850a0b0303c2c2356f775ce1a11eb3bef575bd32 ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // holo-tmdb.mjs β€” the metadata provider: TMDb β†’ the ONE Holo Player item shape, ΞΊ-cached.
2
+ //
3
+ // This is Jellyfin's built-in metadata providers + Jellyseerr's discovery, done once and content-addressed.
4
+ // It auto-populates Holo Player with every popular movie + series (titles, posters, backdrops, overviews,
5
+ // genres, cast, ratings, trailers, and "where to watch") β€” and because every response goes THROUGH
6
+ // holo-media-cache (a ΞΊ-object keyed by the request), a warm open is memory-speed and works offline.
7
+ //
8
+ // Dependency-injected (fetch + cache) so Node witnesses it with a fake TMDb and a Map cache β€” no network,
9
+ // no key. The browser binding (window.HoloTmdb) wires real fetch + the persistent ΞΊ-cache.
10
+
11
+ import { normalizeMovie, normalizeSeries, normalizeEpisode, normalizeWatchProviders, normalizeAny } from "./holo-media-item.mjs";
12
+
13
+ const API = "https://api.themoviedb.org/3";
14
+
15
+ // createTmdb({ apiKey, fetch, cache, region, lang }) β€” fetch/cache injectable. apiKey: v3 key (query) or v4
16
+ // bearer token (auto-detected by the dot in a JWT). cache: a holo-media-cache instance (or null = no cache).
17
+ export function createTmdb({ apiKey = "", fetch: f, cache = null, region = "US", lang = "en-US" } = {}) {
18
+ const doFetch = f || (typeof fetch !== "undefined" ? fetch.bind(globalThis) : null);
19
+ if (!doFetch) throw new Error("holo-tmdb: a fetch implementation is required");
20
+ const bearer = apiKey.includes("."); // v4 tokens are JWTs
21
+
22
+ function url(path, params = {}) {
23
+ const u = new URL(API + path);
24
+ u.searchParams.set("language", lang);
25
+ if (!bearer && apiKey) u.searchParams.set("api_key", apiKey);
26
+ for (const [k, v] of Object.entries(params)) if (v != null) u.searchParams.set(k, String(v));
27
+ return u.toString();
28
+ }
29
+ async function raw(path, params) {
30
+ const res = await doFetch(url(path, params), bearer ? { headers: { Authorization: "Bearer " + apiKey } } : undefined);
31
+ if (!res.ok) throw new Error("tmdb " + res.status + " " + path);
32
+ return res.json();
33
+ }
34
+ // every network call goes through the ΞΊ-cache: same request β†’ same ΞΊ β†’ O(1) warm/offline read.
35
+ // bucket:"day" stamps the key with today β€” discovery rails self-refresh daily instead of freezing at
36
+ // their first-ever fetch (title/season records are stable, so they stay permanent).
37
+ async function api(path, params = {}, { bucket = null } = {}) {
38
+ const key = path + "?" + new URLSearchParams(params).toString() + "|" + lang + "|" + region + (bucket === "day" ? "|d:" + new Date().toISOString().slice(0, 10) : "");
39
+ if (!cache) return raw(path, params);
40
+ const { body } = await cache.through(key, () => raw(path, params));
41
+ return body;
42
+ }
43
+
44
+ const list = (payload, opts) => (payload && payload.results ? payload.results : []).map((t) => normalizeAny(t, opts)).filter((x) => x.posterUrl || x.backdrop);
45
+
46
+ return {
47
+ // ── discovery rails ──────────────────────────────────────────────────────────────────────────────
48
+ async trending(window = "week") { return list(await api(`/trending/all/${window}`, {}, { bucket: "day" })); },
49
+ async popularMovies(page = 1) { return (await api("/movie/popular", { page }, { bucket: "day" })).results.map((t) => normalizeMovie(t)); },
50
+ async popularSeries(page = 1) { return (await api("/tv/popular", { page }, { bucket: "day" })).results.map((t) => normalizeSeries(t)); },
51
+ async topRatedMovies(page = 1) { return (await api("/movie/top_rated", { page }, { bucket: "day" })).results.map((t) => normalizeMovie(t)); },
52
+ async byGenre(genreId, kind = "movie", page = 1) {
53
+ const p = await api(kind === "tv" ? "/discover/tv" : "/discover/movie", { with_genres: genreId, sort_by: "popularity.desc", page });
54
+ return p.results.map((t) => (kind === "tv" ? normalizeSeries(t) : normalizeMovie(t)));
55
+ },
56
+ async search(q, page = 1) { return list(await api("/search/multi", { query: q, page, include_adult: false })); },
57
+
58
+ // ── one title, enriched (videos β†’ trailer, credits β†’ cast, watch/providers β†’ "where to watch") ─────
59
+ async title(id, kind = "movie") {
60
+ const tv = kind === "tv";
61
+ // pull EVERYTHING in one call: trailer, full cast+crew, logo art, keywords, certification, recommendations,
62
+ // similar, imdb id. include_image_language gets the English title-logo PNG. The ΞΊ-cache makes repeats free.
63
+ const append = tv
64
+ ? "videos,credits,images,keywords,recommendations,similar,external_ids,content_ratings,watch/providers"
65
+ : "videos,credits,images,keywords,release_dates,recommendations,similar,external_ids,watch/providers";
66
+ const t = await api(`/${tv ? "tv" : "movie"}/${id}`, { append_to_response: append, include_image_language: "en,null" });
67
+ t.providers = normalizeWatchProviders(t["watch/providers"], region); // normalizers extract the rest
68
+ return tv ? normalizeSeries(t, { region }) : normalizeMovie(t, { region });
69
+ },
70
+ // ── a season's episodes (the Series β†’ Season β†’ Episode browser) ────────────────────────────────────
71
+ async season(seriesId, seasonNumber, series) {
72
+ const s = await api(`/tv/${seriesId}/season/${seasonNumber}`);
73
+ const ctx = series || { tmdbId: seriesId, name: "", topics: [], genres: [], backdrop: null, posterUrl: null, quality: 0.7 };
74
+ return (s.episodes || []).map((ep) => normalizeEpisode(ep, ctx));
75
+ },
76
+ async watchProviders(id, kind = "movie") { return normalizeWatchProviders(await api(`/${kind === "tv" ? "tv" : "movie"}/${id}/watch/providers`), region); },
77
+ };
78
+ }
79
+
80
+ // Built-in community key β€” the metadata plane works ZERO-SETUP for every visitor (a TMDb v3 read key is
81
+ // designed to ship client-side). A user key in Settings still overrides it. Every response is ΞΊ-cached,
82
+ // so most opens never re-touch the network anyway.
83
+ const BUILTIN_KEY = "45f791c5f6a6940a50403bb3890c7b86";
84
+
85
+ // browser binding β€” real fetch + the persistent ΞΊ-cache + built-in key (localStorage key overrides).
86
+ if (typeof window !== "undefined") {
87
+ window.HoloTmdb = {
88
+ createTmdb,
89
+ // live({ apiKey }) β€” the one the player calls. Key from arg, localStorage "holoplayer.tmdb.key", or built-in.
90
+ live(opts = {}) {
91
+ const apiKey = opts.apiKey || (() => { try { return localStorage.getItem("holoplayer.tmdb.key") || ""; } catch { return ""; } })() || BUILTIN_KEY;
92
+ const cache = window.HoloMediaCache ? window.HoloMediaCache.live() : null;
93
+ return createTmdb({ apiKey, cache, region: opts.region || "US", lang: opts.lang || "en-US" });
94
+ },
95
+ configured() { return true; }, // built-in key: always on
96
+ userKey() { try { return !!localStorage.getItem("holoplayer.tmdb.key"); } catch { return false; } },
97
+ setKey(k) { try { localStorage.setItem("holoplayer.tmdb.key", k || ""); } catch {} },
98
+ };
99
+ }
100
+
101
+ export default { createTmdb };
b/7ebd737e91193f4aa2412554ac6f366fbc35d53f5878b6ddf46dfa6645184fb5 ADDED
@@ -0,0 +1,616 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "$comment": "Seed catalog so Holo Player is ALREADY FULL on first open β€” no TMDb key, offline. Real popular titles with accurate TMDb ids + metadata (genres/year/rating/overview). Each carries a verified imdb_id so the normalizer fills real, beautiful poster/backdrop art KEY-FREE via the Stremio metahub CDN (the same source Cinemeta uses); a configured TMDb key still live-upgrades to TMDb's own art/trailers. Each item is TMDb-shaped (media_type=movie|tv, genre_ids) so holo-media-item normalizers consume it exactly like a live TMDb row.",
3
+ "generatedAt": "2026-07-11",
4
+ "items": [
5
+ {
6
+ "id": 1339713,
7
+ "media_type": "movie",
8
+ "title": "Obsession",
9
+ "imdb_id": "tt37287335",
10
+ "release_date": "2026-05-13",
11
+ "genre_ids": [
12
+ 27,
13
+ 53
14
+ ],
15
+ "vote_average": 8.3,
16
+ "runtime": 109,
17
+ "poster_path": null,
18
+ "backdrop_path": null,
19
+ "overview": "After breaking the mysterious \"One Wish Willow\" to win his crush's heart, a hopeless romantic finds himself getting exactly what he asked for but soon discovers that some desires come at a dark, sinister price."
20
+ },
21
+ {
22
+ "id": 1280738,
23
+ "media_type": "movie",
24
+ "title": "The Furious",
25
+ "imdb_id": "tt33311069",
26
+ "release_date": "2025-09-07",
27
+ "genre_ids": [
28
+ 28,
29
+ 80,
30
+ 53
31
+ ],
32
+ "vote_average": 7.5,
33
+ "runtime": 113,
34
+ "poster_path": null,
35
+ "backdrop_path": null,
36
+ "overview": "After a criminal network kidnaps Wang Wei's daughter and the corrupt police refuse to assist him, Wei sets out on his own to locate her. Navin, a tenacious journalist whose wife has mysteriously vanished, is his only ally. In this explosive martial arts showdown, the unlikely duo fights the kidnappers ruthlessly driven by a furious vengeance."
37
+ },
38
+ {
39
+ "id": 125988,
40
+ "media_type": "tv",
41
+ "name": "Silo",
42
+ "imdb_id": "tt14688458",
43
+ "first_air_date": "2023-05-04",
44
+ "genre_ids": [
45
+ 10765,
46
+ 18
47
+ ],
48
+ "vote_average": 8.2,
49
+ "number_of_seasons": 3,
50
+ "number_of_episodes": 30,
51
+ "poster_path": null,
52
+ "backdrop_path": null,
53
+ "overview": "In a ruined and toxic future, thousands live in a giant silo deep underground. After its sheriff breaks a cardinal rule and residents die mysteriously, engineer Juliette starts to uncover shocking secrets and the truth about the silo."
54
+ },
55
+ {
56
+ "id": 1368314,
57
+ "media_type": "movie",
58
+ "title": "Passenger",
59
+ "imdb_id": "tt33763941",
60
+ "release_date": "2026-05-20",
61
+ "genre_ids": [
62
+ 27,
63
+ 53
64
+ ],
65
+ "vote_average": 6.7,
66
+ "runtime": 94,
67
+ "poster_path": null,
68
+ "backdrop_path": null,
69
+ "overview": "After a young couple witnesses a gruesome highway accident, they soon realize they did not leave the crash scene alone, as a demonic presence called the Passenger that won't stop until it claims them both turns their van life adventure into a nightmare."
70
+ },
71
+ {
72
+ "id": 1212763,
73
+ "media_type": "movie",
74
+ "title": "Evil Dead Burn",
75
+ "imdb_id": "tt31170389",
76
+ "release_date": "2026-07-07",
77
+ "genre_ids": [
78
+ 27,
79
+ 53
80
+ ],
81
+ "vote_average": 7.2,
82
+ "runtime": 109,
83
+ "poster_path": null,
84
+ "backdrop_path": null,
85
+ "overview": "After her husband's abrupt death, a woman seeks solace with her in-laws. As they transform into Deadites one by one, she comes to discover that the vows she took in life survive even in death."
86
+ },
87
+ {
88
+ "id": 94997,
89
+ "media_type": "tv",
90
+ "name": "House of the Dragon",
91
+ "imdb_id": "tt11198330",
92
+ "first_air_date": "2022-08-21",
93
+ "genre_ids": [
94
+ 10765,
95
+ 18,
96
+ 10759
97
+ ],
98
+ "vote_average": 8.4,
99
+ "number_of_seasons": 3,
100
+ "number_of_episodes": 26,
101
+ "poster_path": null,
102
+ "backdrop_path": null,
103
+ "overview": "The Targaryen dynasty is at the absolute apex of its power, with more than 15 dragons under their yoke. Most empires crumble from such heights. In the case of the Targaryens, their slow fall begins when King Viserys breaks with a century of tradition by naming his daughter Rhaenyra heir to the Iron Throne. But when Viserys later fathers a son, the court is shocked when Rhaenyra retains her status "
104
+ },
105
+ {
106
+ "id": 1084244,
107
+ "media_type": "movie",
108
+ "title": "Toy Story 5",
109
+ "imdb_id": "tt29355505",
110
+ "release_date": "2026-06-17",
111
+ "genre_ids": [
112
+ 16,
113
+ 10751,
114
+ 35,
115
+ 12
116
+ ],
117
+ "vote_average": 7.4,
118
+ "runtime": 102,
119
+ "poster_path": null,
120
+ "backdrop_path": null,
121
+ "overview": "When Bonnie receives a Lilypad tablet as a gift and becomes obsessed, Buzz, Woody, Jessie and the rest of the gang's jobs become exponentially harder when they have to go head to head with the all-new threat to playtime."
122
+ },
123
+ {
124
+ "id": 1083381,
125
+ "media_type": "movie",
126
+ "title": "Backrooms",
127
+ "imdb_id": "tt26657236",
128
+ "release_date": "2026-05-27",
129
+ "genre_ids": [
130
+ 27,
131
+ 9648,
132
+ 878
133
+ ],
134
+ "vote_average": 6.8,
135
+ "runtime": 111,
136
+ "poster_path": null,
137
+ "backdrop_path": null,
138
+ "overview": "A strange doorway appears in the basement of a furniture showroom."
139
+ },
140
+ {
141
+ "id": 1202033,
142
+ "media_type": "movie",
143
+ "title": "Enola Holmes 3",
144
+ "imdb_id": "tt32278481",
145
+ "release_date": "2026-06-30",
146
+ "genre_ids": [
147
+ 12,
148
+ 80,
149
+ 9648
150
+ ],
151
+ "vote_average": 7,
152
+ "runtime": 109,
153
+ "poster_path": null,
154
+ "backdrop_path": null,
155
+ "overview": "Adventure follows detective Enola Holmes to Malta, where her plans to tie the knot unravel when Sherlock's disappearance plunges her into a perilous case."
156
+ },
157
+ {
158
+ "id": 94664,
159
+ "media_type": "tv",
160
+ "name": "Mushoku Tensei: Jobless Reincarnation",
161
+ "imdb_id": "tt13293588",
162
+ "first_air_date": "2021-01-11",
163
+ "genre_ids": [
164
+ 10759,
165
+ 16,
166
+ 10765
167
+ ],
168
+ "vote_average": 8.5,
169
+ "number_of_seasons": 3,
170
+ "number_of_episodes": 61,
171
+ "poster_path": null,
172
+ "backdrop_path": null,
173
+ "overview": "When a 34-year-old underachiever gets run over by a truck, his story doesn't end there. Reincarnated in a new world as an infant, Rudy will seize every opportunity to live the life he's always wanted. Armed with new friends, some freshly acquired magical abilities, and the courage to do the things he's always dreamed of, he's embarking on an epic adventureβ€”with all of his past experience intact!"
174
+ },
175
+ {
176
+ "id": 37854,
177
+ "media_type": "tv",
178
+ "name": "One Piece",
179
+ "imdb_id": "tt0388629",
180
+ "first_air_date": "1999-10-20",
181
+ "genre_ids": [
182
+ 10759,
183
+ 35,
184
+ 16
185
+ ],
186
+ "vote_average": 8.7,
187
+ "number_of_seasons": 23,
188
+ "number_of_episodes": 1169,
189
+ "poster_path": null,
190
+ "backdrop_path": null,
191
+ "overview": "Years ago, the fearsome Pirate King, Gol D. Roger was executed leaving a huge pile of treasure and the famous \"One Piece\" behind. Whoever claims the \"One Piece\" will be named the new King of the Pirates.\n\nMonkey D. Luffy, a boy who consumed a \"Devil Fruit,\" decides to follow in the footsteps of his idol, the pirate Shanks, and find the One Piece. It helps, of course, that his body has the properti"
192
+ },
193
+ {
194
+ "id": 106449,
195
+ "media_type": "tv",
196
+ "name": "A Record of a Mortal's Journey to Immortality",
197
+ "imdb_id": "tt12879782",
198
+ "first_air_date": "2020-07-25",
199
+ "genre_ids": [
200
+ 16,
201
+ 10759,
202
+ 10765
203
+ ],
204
+ "vote_average": 8.4,
205
+ "number_of_seasons": 1,
206
+ "number_of_episodes": 190,
207
+ "poster_path": null,
208
+ "backdrop_path": null,
209
+ "overview": "A poor and ordinary boy from a village joins a minor sect in Jiang Hu and becomes an Unofficial Disciple by chance. How will Han Li, a commoner by birth, establish a foothold for himself in his sect? With his mediocre aptitude, he must successfully traverse the treacherous path of cultivation and avoid the notice of those who may do him harm. This is a story of an ordinary mortal who, against all "
210
+ },
211
+ {
212
+ "id": 1315772,
213
+ "media_type": "movie",
214
+ "title": "Minions & Monsters",
215
+ "imdb_id": "tt32890033",
216
+ "release_date": "2026-06-24",
217
+ "genre_ids": [
218
+ 12,
219
+ 16,
220
+ 35,
221
+ 10751,
222
+ 14
223
+ ],
224
+ "vote_average": 6.4,
225
+ "runtime": 90,
226
+ "poster_path": null,
227
+ "backdrop_path": null,
228
+ "overview": "This is the rambunctious, ridiculous and totally true story of how the Minions conquered Hollywood, became movie stars, lost everything, unleashed monsters onto the world and then banded together to try and save the planet from the mayhem they had just created."
229
+ },
230
+ {
231
+ "id": 296206,
232
+ "media_type": "tv",
233
+ "name": "Agent Kim Reactivated",
234
+ "imdb_id": "tt42127457",
235
+ "first_air_date": "2026-06-26",
236
+ "genre_ids": [
237
+ 10759,
238
+ 80,
239
+ 18
240
+ ],
241
+ "vote_average": 7.4,
242
+ "number_of_seasons": 1,
243
+ "number_of_episodes": 10,
244
+ "poster_path": null,
245
+ "backdrop_path": null,
246
+ "overview": "When an unassuming dad's daughter goes missing, he dusts off his old black-ops skills to track her down β€” only to attract the wrong kind of attention."
247
+ },
248
+ {
249
+ "id": 687163,
250
+ "media_type": "movie",
251
+ "title": "Project Hail Mary",
252
+ "imdb_id": "tt12042730",
253
+ "release_date": "2026-03-15",
254
+ "genre_ids": [
255
+ 878,
256
+ 12
257
+ ],
258
+ "vote_average": 8.7,
259
+ "runtime": 157,
260
+ "poster_path": null,
261
+ "backdrop_path": null,
262
+ "overview": "Science teacher Ryland Grace wakes up on a spaceship light years from home with no recollection of who he is or how he got there. As his memory returns, he begins to uncover his mission: solve the riddle of the mysterious substance causing the sun to die out. He must call on his scientific knowledge and unorthodox ideas to save everything on Earth from extinction."
263
+ },
264
+ {
265
+ "id": 936075,
266
+ "media_type": "movie",
267
+ "title": "Michael",
268
+ "imdb_id": "tt11378946",
269
+ "release_date": "2026-04-22",
270
+ "genre_ids": [
271
+ 10402,
272
+ 18
273
+ ],
274
+ "vote_average": 8.7,
275
+ "runtime": 128,
276
+ "poster_path": null,
277
+ "backdrop_path": null,
278
+ "overview": "The story of Michael Jackson, one of the most influential artists the world has ever known, and his life beyond the music. His journey from the discovery of his extraordinary talent as the lead of the Jackson Five, to the visionary artist whose creative ambition fueled a relentless pursuit to become the biggest entertainer in the world, highlighting both his life off-stage and some of the most ico"
279
+ },
280
+ {
281
+ "id": 1275779,
282
+ "media_type": "movie",
283
+ "title": "Disclosure Day",
284
+ "imdb_id": "tt15047880",
285
+ "release_date": "2026-06-10",
286
+ "genre_ids": [
287
+ 878,
288
+ 53
289
+ ],
290
+ "vote_average": 6.7,
291
+ "runtime": 146,
292
+ "poster_path": null,
293
+ "backdrop_path": null,
294
+ "overview": "A cybersecurity expert becomes a whistleblower after uncovering secrets about aliens, putting him on the run from a corporation. Meanwhile, a meteorologist experiencing strange phenomena joins forces with him to prove there's life beyond our understanding."
295
+ },
296
+ {
297
+ "id": 1314481,
298
+ "media_type": "movie",
299
+ "title": "The Devil Wears Prada 2",
300
+ "imdb_id": "tt33612209",
301
+ "release_date": "2026-04-29",
302
+ "genre_ids": [
303
+ 35,
304
+ 18
305
+ ],
306
+ "vote_average": 7.1,
307
+ "runtime": 119,
308
+ "poster_path": null,
309
+ "backdrop_path": null,
310
+ "overview": "Andy Sachs returns to Runway as Miranda Priestly navigates a new media landscape and Runway's position within. The duo reconnect with former assistant Emily Charlton, now the head of a luxury brand that possesses funding which could ensure Runway's survival."
311
+ },
312
+ {
313
+ "id": 1413976,
314
+ "media_type": "movie",
315
+ "title": "Citizen Vigilante",
316
+ "imdb_id": "tt35309713",
317
+ "release_date": "2026-06-19",
318
+ "genre_ids": [
319
+ 53,
320
+ 28,
321
+ 80
322
+ ],
323
+ "vote_average": 6.5,
324
+ "runtime": 89,
325
+ "poster_path": null,
326
+ "backdrop_path": null,
327
+ "overview": "A man takes justice into his own hands, hunting down criminals. His vigilante crusade makes him a social media star but puts him at odds with the local police chief."
328
+ },
329
+ {
330
+ "id": 1523145,
331
+ "media_type": "movie",
332
+ "title": "Your Heart Will Be Broken",
333
+ "imdb_id": "tt38190257",
334
+ "release_date": "2026-03-26",
335
+ "genre_ids": [
336
+ 10749
337
+ ],
338
+ "vote_average": 7.1,
339
+ "runtime": 134,
340
+ "poster_path": null,
341
+ "backdrop_path": null,
342
+ "overview": "High school student Polina is saved from bullying at her new school and makes a deal with the main bully Bars: he must pretend to be her boyfriend and protect her, and she must do everything he says. During this game, the couple develops real feelings, but her family and classmates have reasons to separate the lovers."
343
+ },
344
+ {
345
+ "id": 1127384,
346
+ "media_type": "movie",
347
+ "title": "Deep Water",
348
+ "imdb_id": "tt29516222",
349
+ "release_date": "2026-04-30",
350
+ "genre_ids": [
351
+ 27,
352
+ 53
353
+ ],
354
+ "vote_average": 7.3,
355
+ "runtime": 107,
356
+ "poster_path": null,
357
+ "backdrop_path": null,
358
+ "overview": "A group of international passengers on a flight from Los Angeles to Shanghai is forced to make an emergency landing in shark-infested waters. The terrified group is forced to work together and overcome their differences if they hope to escape their sinking plane and the frenzy of sharks drawn to the wreckage."
359
+ },
360
+ {
361
+ "id": 278,
362
+ "media_type": "movie",
363
+ "title": "The Shawshank Redemption",
364
+ "imdb_id": "tt0111161",
365
+ "release_date": "1994-09-23",
366
+ "genre_ids": [
367
+ 18,
368
+ 80
369
+ ],
370
+ "vote_average": 8.7,
371
+ "runtime": 142,
372
+ "poster_path": null,
373
+ "backdrop_path": null,
374
+ "overview": "Imprisoned in the 1940s for the double murder of his wife and her lover, upstanding banker Andy Dufresne begins a new life at the Shawshank prison, where he puts his accounting skills to work for an amoral warden. During his long stretch in prison, Dufresne comes to be admired by the other inmates -- including an older prisoner named Red -- for his integrity and unquenchable sense of hope."
375
+ },
376
+ {
377
+ "id": 1279493,
378
+ "media_type": "movie",
379
+ "title": "The Get Out",
380
+ "imdb_id": "tt32321803",
381
+ "release_date": "2026-06-18",
382
+ "genre_ids": [
383
+ 28,
384
+ 53
385
+ ],
386
+ "vote_average": 6.4,
387
+ "runtime": 111,
388
+ "poster_path": null,
389
+ "backdrop_path": null,
390
+ "overview": "A nightclub owner is on the verge of leaving his dangerous past behind for retirement with his girlfriend. When masked gunmen rob him and he finds himself squeezed by ruthless cartels, a mysterious newcomer arrives with an interest in buying the business. With danger closing in from all sides, he must navigate a deadly web of deception, power, and survival - where escape may no longer be an opti"
391
+ },
392
+ {
393
+ "id": 931285,
394
+ "media_type": "movie",
395
+ "title": "Mortal Kombat II",
396
+ "imdb_id": "tt17490712",
397
+ "release_date": "2026-05-06",
398
+ "genre_ids": [
399
+ 28,
400
+ 14,
401
+ 12
402
+ ],
403
+ "vote_average": 8,
404
+ "runtime": 116,
405
+ "poster_path": null,
406
+ "backdrop_path": null,
407
+ "overview": "The fan favorite championsβ€”now joined by Johnny Cage himselfβ€”are pitted against one another in the ultimate, no-holds barred, gory battle to defeat the dark rule of Shao Kahn that threatens the very existence of the Earthrealm and its defenders."
408
+ },
409
+ {
410
+ "id": 11012,
411
+ "media_type": "movie",
412
+ "title": "Damage",
413
+ "imdb_id": "tt0104237",
414
+ "release_date": "1992-12-02",
415
+ "genre_ids": [
416
+ 18,
417
+ 10749
418
+ ],
419
+ "vote_average": 6.6,
420
+ "runtime": 111,
421
+ "poster_path": null,
422
+ "backdrop_path": null,
423
+ "overview": "The life of a respected British politician at the height of his career crumbles when he becomes obsessed with his son's lover."
424
+ },
425
+ {
426
+ "id": 243206,
427
+ "media_type": "tv",
428
+ "name": "Pritam and Pedro",
429
+ "imdb_id": "tt28077038",
430
+ "first_air_date": "2026-07-03",
431
+ "genre_ids": [
432
+ 80,
433
+ 18,
434
+ 9648,
435
+ 35
436
+ ],
437
+ "vote_average": 8.5,
438
+ "number_of_seasons": 1,
439
+ "number_of_episodes": 6,
440
+ "poster_path": null,
441
+ "backdrop_path": null,
442
+ "overview": "The dynamic between the two contrasting personalities, a seasoned cop who prefers old-school methods and a tech-savvy cop who relies on modern technology for investigations, as they navigate their partnership in solving crimes."
443
+ },
444
+ {
445
+ "id": 312949,
446
+ "media_type": "tv",
447
+ "name": "Chainsmoker Cat",
448
+ "imdb_id": "tt39551330",
449
+ "first_air_date": "2026-07-03",
450
+ "genre_ids": [
451
+ 16,
452
+ 35,
453
+ 10765
454
+ ],
455
+ "vote_average": 8.9,
456
+ "number_of_seasons": 1,
457
+ "number_of_episodes": 12,
458
+ "poster_path": null,
459
+ "backdrop_path": null,
460
+ "overview": "Catgirl Yani struggles to pay her rent and keep a job while feeding her smoking habit. As her loved ones' concerns grow, she must rethink her future."
461
+ },
462
+ {
463
+ "id": 124364,
464
+ "media_type": "tv",
465
+ "name": "FROM",
466
+ "imdb_id": "tt9813792",
467
+ "first_air_date": "2022-02-20",
468
+ "genre_ids": [
469
+ 9648,
470
+ 18,
471
+ 10765
472
+ ],
473
+ "vote_average": 8.5,
474
+ "number_of_seasons": 4,
475
+ "number_of_episodes": 40,
476
+ "poster_path": null,
477
+ "backdrop_path": null,
478
+ "overview": "Unravel the mystery of a nightmarish town in middle America that traps all those who enter. As the unwilling residents fight to keep a sense of normalcy and search for a way out, they must also survive the threats of the surrounding forest – including the terrifying creatures that come out when the sun goes down."
479
+ },
480
+ {
481
+ "id": 2734,
482
+ "media_type": "tv",
483
+ "name": "Law & Order: Special Victims Unit",
484
+ "imdb_id": "tt0203259",
485
+ "first_air_date": "1999-09-20",
486
+ "genre_ids": [
487
+ 80,
488
+ 18,
489
+ 9648
490
+ ],
491
+ "vote_average": 8,
492
+ "number_of_seasons": 28,
493
+ "number_of_episodes": 595,
494
+ "poster_path": null,
495
+ "backdrop_path": null,
496
+ "overview": "In the criminal justice system, sexually-based offenses are considered especially heinous. In New York City, the dedicated detectives who investigate these vicious felonies are members of an elite squad known as the Special Victims Unit. These are their stories."
497
+ },
498
+ {
499
+ "id": 79744,
500
+ "media_type": "tv",
501
+ "name": "The Rookie",
502
+ "imdb_id": "tt7587890",
503
+ "first_air_date": "2018-10-16",
504
+ "genre_ids": [
505
+ 80,
506
+ 18,
507
+ 35
508
+ ],
509
+ "vote_average": 8.5,
510
+ "number_of_seasons": 9,
511
+ "number_of_episodes": 144,
512
+ "poster_path": null,
513
+ "backdrop_path": null,
514
+ "overview": "Starting over isn't easy, especially for small-town guy John Nolan who, after a life-altering incident, is pursuing his dream of being an LAPD officer. As the force's oldest rookie, he's met with skepticism from some higher-ups who see him as just a walking midlife crisis."
515
+ },
516
+ {
517
+ "id": 241002,
518
+ "media_type": "tv",
519
+ "name": "Adam's Sweet Agony",
520
+ "imdb_id": "tt30325611",
521
+ "first_air_date": "2024-01-08",
522
+ "genre_ids": [
523
+ 16
524
+ ],
525
+ "vote_average": 6.9,
526
+ "number_of_seasons": 1,
527
+ "number_of_episodes": 8,
528
+ "poster_path": null,
529
+ "backdrop_path": null,
530
+ "overview": "This is the story of a boy, who became the lone Adam among four billion Eves. In a world where a pandemic has rendered all men impotent, high school student Itsuki is the exception who escaped it. In order to protect this secret, he transfers to a very special high school, which turns out to be composed of 90% girls! There, he encounters an upbeat and friendly senior, a sexually frustrated female "
531
+ },
532
+ {
533
+ "id": 5920,
534
+ "media_type": "tv",
535
+ "name": "The Mentalist",
536
+ "imdb_id": "tt1196946",
537
+ "first_air_date": "2008-09-23",
538
+ "genre_ids": [
539
+ 80,
540
+ 18,
541
+ 9648
542
+ ],
543
+ "vote_average": 8.4,
544
+ "number_of_seasons": 7,
545
+ "number_of_episodes": 151,
546
+ "poster_path": null,
547
+ "backdrop_path": null,
548
+ "overview": "Patrick Jane, a former celebrity psychic medium, uses his razor sharp skills of observation and expertise at \"reading\" people to solve serious crimes with the California Bureau of Investigation."
549
+ },
550
+ {
551
+ "id": 549,
552
+ "media_type": "tv",
553
+ "name": "Law & Order",
554
+ "imdb_id": "tt0098844",
555
+ "first_air_date": "1990-09-13",
556
+ "genre_ids": [
557
+ 80,
558
+ 18
559
+ ],
560
+ "vote_average": 7.3,
561
+ "number_of_seasons": 26,
562
+ "number_of_episodes": 545,
563
+ "poster_path": null,
564
+ "backdrop_path": null,
565
+ "overview": "In cases ripped from the headlines, police investigate serious and often deadly crimes, weighing the evidence and questioning the suspects until someone is taken into custody. The district attorney's office then builds a case to convict the perpetrator by proving the person guilty beyond a reasonable doubt. Working together, these expert teams navigate all sides of the complex criminal justice sys"
566
+ },
567
+ {
568
+ "id": 1622,
569
+ "media_type": "tv",
570
+ "name": "Supernatural",
571
+ "imdb_id": "tt0460681",
572
+ "first_air_date": "2005-09-13",
573
+ "genre_ids": [
574
+ 18,
575
+ 9648,
576
+ 10765
577
+ ],
578
+ "vote_average": 8.3,
579
+ "number_of_seasons": 15,
580
+ "number_of_episodes": 327,
581
+ "poster_path": null,
582
+ "backdrop_path": null,
583
+ "overview": "When they were boys, Sam and Dean Winchester lost their mother to a mysterious and demonic supernatural force. Subsequently, their father raised them to be soldiers. He taught them about the paranormal evil that lives in the dark corners and on the back roads of America ... and he taught them how to kill it. Now, the Winchester brothers crisscross the country in their '67 Chevy Impala, battling ev"
584
+ },
585
+ {
586
+ "id": 1416,
587
+ "media_type": "tv",
588
+ "name": "Grey's Anatomy",
589
+ "imdb_id": "tt0413573",
590
+ "first_air_date": "2005-03-27",
591
+ "genre_ids": [
592
+ 18
593
+ ],
594
+ "vote_average": 8.2,
595
+ "number_of_seasons": 22,
596
+ "number_of_episodes": 465,
597
+ "poster_path": null,
598
+ "backdrop_path": null,
599
+ "overview": "Follows the personal and professional lives of a group of doctors at Seattle's Grey Sloan Memorial Hospital."
600
+ },
601
+ {
602
+ "id": 4304,
603
+ "media_type": "tv",
604
+ "name": "Xplay",
605
+ "imdb_id": "tt0361258",
606
+ "first_air_date": "2003-04-28",
607
+ "genre_ids": [],
608
+ "vote_average": 8.5,
609
+ "number_of_seasons": 10,
610
+ "number_of_episodes": 644,
611
+ "poster_path": null,
612
+ "backdrop_path": null,
613
+ "overview": "Xplay (previously GameSpot TV and Extended Play) is a TV program about video games. The program, known for its reviews and comedy skits, airs on G4 in the United States and had aired on G4 Canada in Canada (and briefly on YTV during its time as GameSpot TV), FUEL TV in Australia, Ego in Israel, GXT in Italy, MTV Russia & Rambler TV in Russia, Solar Sports in the Philippines and Adult Swim and Much"
614
+ }
615
+ ]
616
+ }
b/8eb4171fc2472a04e7b6ec270350035618ea8e1c8e617b2d1ca98ff0ef92667e ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+ // holo-tvdb-witness.mjs β€” proves the TVDB v4 provider gives the player series-depth TMDb can't, with a
3
+ // fake TVDB (no network, no key) and a Map ΞΊ-cache. Same laws as the TMDb witness: ΞΊ-cached (fast +
4
+ // offline), verify-before-trust, plus the two TVDB-specific mechanisms β€” the bearer-token lifecycle and
5
+ // the DAY BUCKET (airing answers refresh daily; the IMDb→TVDB id bridge caches forever).
6
+ //
7
+ // Checks:
8
+ // 1 tokenOnce β€” first call logs in once; later calls reuse the stored token (no re-login).
9
+ // 2 tokenRefresh β€” a 401 mid-flight re-logins ONCE and retries (expired token heals itself).
10
+ // 3 remoteBridge β€” an IMDb id resolves to the TVDB series id (the identity bridge).
11
+ // 4 airingNext β€” airingOf(series item) answers status + nextAired + the next episode.
12
+ // 5 episodesNormalizedβ€” seasonEpisodes returns the player's ONE episode shape (numbers, art, dates).
13
+ // 6 kappaCacheHit β€” a repeated request serves from ΞΊ (0 extra fetches).
14
+ // 7 worksOffline β€” with a warm cache, a request whose network is DOWN still serves from ΞΊ.
15
+ // 8 dayBucket β€” the same airing question tomorrow re-fetches (fresh), but the id bridge does NOT.
16
+ //
17
+ // node holo-tvdb-witness.mjs (from holo-apps/apps/player/)
18
+
19
+ import { writeFileSync } from "node:fs";
20
+ import { fileURLToPath } from "node:url";
21
+ import { dirname, join } from "node:path";
22
+ import { createTvdb } from "./holo-tvdb.mjs";
23
+ import { createMediaCache, memKV } from "./holo-media-cache.mjs";
24
+
25
+ const here = dirname(fileURLToPath(import.meta.url));
26
+ const checks = {}; const fail = [];
27
+ const ok = (n, c, d = "") => { checks[n] = !!c; if (!c) fail.push(n + (d ? ` β€” ${d}` : "")); return !!c; };
28
+
29
+ // ── fake TVDB (counts calls; routes by path) ─────────────────────────────────────────────────────────────
30
+ const b64 = (o) => Buffer.from(JSON.stringify(o)).toString("base64");
31
+ const JWT = (expSec) => `x.${b64({ exp: expSec })}.y`;
32
+ const SERIES = {
33
+ id: 366972, name: "Foundation", status: { name: "Continuing" }, nextAired: "2026-07-14", lastAired: "2026-07-07",
34
+ episodes: [
35
+ { id: 1, name: "The Emperor's Peace", aired: "2021-09-24", runtime: 69, number: 1, seasonNumber: 1, overview: "Gaal arrives.", image: "/banners/ep1.jpg" },
36
+ { id: 2, name: "Preparing to Live", aired: "2021-09-24", runtime: 59, number: 2, seasonNumber: 1, overview: "", image: null },
37
+ { id: 3, name: "The Next One", aired: "2026-07-14", runtime: 55, number: 1, seasonNumber: 4, overview: "", image: null },
38
+ { id: 9, name: "Special", aired: "2021-01-01", runtime: 10, number: 1, seasonNumber: 0, overview: "", image: null },
39
+ ],
40
+ };
41
+ let logins = 0, calls = 0, netDown = false, expireNext = false;
42
+ const fakeFetch = async (url, opts = {}) => {
43
+ const path = new URL(url).pathname;
44
+ if (path === "/v4/login") { logins++; return { ok: true, status: 200, json: async () => ({ status: "success", data: { token: JWT(Math.floor(Date.now() / 1000) + 30 * 86400) } }) }; }
45
+ if (netDown) throw new Error("network down");
46
+ calls++;
47
+ if (expireNext) { expireNext = false; return { ok: false, status: 401, json: async () => ({ message: "Unauthorized" }) }; }
48
+ if (path.startsWith("/v4/search/remoteid/")) return { ok: true, status: 200, json: async () => ({ data: [{ series: { id: 366972, name: "Foundation" } }] }) };
49
+ if (path.startsWith("/v4/series/366972/extended")) return { ok: true, status: 200, json: async () => ({ data: SERIES }) };
50
+ return { ok: false, status: 404, json: async () => ({}) };
51
+ };
52
+
53
+ let NOW = Date.parse("2026-07-11T12:00:00Z");
54
+ const cache = createMediaCache({ kv: memKV() });
55
+ const tvdb = createTvdb({ apikey: "witness-key", fetch: fakeFetch, cache, now: () => NOW });
56
+
57
+ // 1 tokenOnce β€” two calls, one login.
58
+ const idA = await tvdb.seriesIdByRemote("tt0804484");
59
+ await tvdb.series(366972);
60
+ ok("tokenOnce", logins === 1, `logins=${logins}`);
61
+
62
+ // 2 tokenRefresh β€” next raw call answers 401 once β†’ exactly one extra login, request still succeeds.
63
+ expireNext = true;
64
+ const s2 = await tvdb.series(366973).catch(() => null); // uncached path (different id β†’ 404 after retry is fine)
65
+ ok("tokenRefresh", logins === 2, `logins=${logins}`);
66
+
67
+ // 3 remoteBridge
68
+ ok("remoteBridge", idA === 366972, String(idA));
69
+
70
+ // 4 airingNext β€” via a player-shaped item (imdbId bridge), status + nextAired + next episode.
71
+ const a = await tvdb.airingOf({ kind: "series", imdbId: "tt0804484", name: "Foundation" });
72
+ ok("airingNext", !!a && a.status === "Continuing" && a.nextAired === "2026-07-14" && a.nextEpisode && a.nextEpisode.seasonNumber === 4, JSON.stringify(a));
73
+
74
+ // 5 episodesNormalized β€” ONE shape: ids, numbers, seconds, art host, air date; season 0 specials excluded.
75
+ const eps = await tvdb.seasonEpisodes({ kind: "series", imdbId: "tt0804484", name: "Foundation" }, 1);
76
+ ok("episodesNormalized",
77
+ eps.length === 2 && eps[0].kind === "episode" && eps[0].episodeNumber === 1 && eps[0].seasonNumber === 1 &&
78
+ eps[0].runtimeSec === 69 * 60 && eps[0].releaseDate === "2021-09-24" && /artworks\.thetvdb\.com/.test(eps[0].posterUrl || "") &&
79
+ eps[0].source === "tvdb" && eps[0].availability && eps[0].availability.playable === false,
80
+ JSON.stringify(eps[0] || null));
81
+
82
+ // 6 kappaCacheHit β€” repeat the same series read: no new network calls.
83
+ const before = calls; await tvdb.series(366972);
84
+ ok("kappaCacheHit", calls === before, `calls ${before}β†’${calls}`);
85
+
86
+ // 7 worksOffline β€” network gone, warm ΞΊ still answers.
87
+ netDown = true;
88
+ const off = await tvdb.series(366972).catch(() => null);
89
+ ok("worksOffline", !!off && off.name === "Foundation", off ? off.name : "null");
90
+ netDown = false;
91
+
92
+ // 8 dayBucket β€” tomorrow the airing answer re-fetches (fresh), the id bridge does NOT (permanent).
93
+ NOW += 864e5;
94
+ const c0 = calls; await tvdb.series(366972); const afterSeries = calls;
95
+ await tvdb.seriesIdByRemote("tt0804484"); const afterBridge = calls;
96
+ ok("dayBucket", afterSeries === c0 + 1 && afterBridge === afterSeries, `series +${afterSeries - c0}, bridge +${afterBridge - afterSeries}`);
97
+
98
+ // ── verdict ──────────────────────────────────────────────────────────────────────────────────────────────
99
+ const pass = fail.length === 0;
100
+ console.log("\nholo-tvdb witness β€” series depth (air dates, status, next episode), ΞΊ-cached + offline\n");
101
+ for (const [n, c] of Object.entries(checks)) console.log(` ${c ? "βœ“" : "βœ—"} ${n}`);
102
+ console.log(pass ? "\n WITNESSED βœ“ the next episode is known, served from ΞΊ β€” fast + offline + self-refreshing daily\n" : `\n FAILED: ${fail.join("; ")}\n`);
103
+ writeFileSync(join(here, "holo-tvdb-witness.result.json"), JSON.stringify({ when: new Date().toISOString(), pass, checks, fail }, null, 2));
104
+ process.exit(pass ? 0 : 1);
b/92b8ede1aa37a9b5b0b92bbd2b0f740691dbd920440e661ea8c1605a2e88a4ad ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "when": "2026-07-11T07:30:15.171Z",
3
+ "pass": true,
4
+ "checks": {
5
+ "tokenOnce": true,
6
+ "tokenRefresh": true,
7
+ "remoteBridge": true,
8
+ "airingNext": true,
9
+ "episodesNormalized": true,
10
+ "kappaCacheHit": true,
11
+ "worksOffline": true,
12
+ "dayBucket": true
13
+ },
14
+ "fail": []
15
+ }
b/e9d10ec2b90cc4f62e0acc03288efac3e53040d4bc17f0d71432b284fb8613ed ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // holo-tvdb.mjs β€” TheTVDB v4 provider: the series depth TMDb can't give β€” episode air dates, series
2
+ // status (Continuing/Ended), and THE NEXT EPISODE. Same discipline as holo-tmdb: dependency-injected
3
+ // (fetch + cache + clock) so Node witnesses it with a fake TVDB β€” no network, no key; every response
4
+ // goes through holo-media-cache (a ΞΊ-object keyed by the request) so a warm open is memory-speed and
5
+ // works offline. Time-variant answers (airing) carry a DAY BUCKET in the request key: fresh once a day,
6
+ // O(1) for the rest of it. The identity bridge is /search/remoteid β€” the player's items carry IMDb/TMDb
7
+ // ids, and that mapping is permanent, so it ΞΊ-caches forever.
8
+
9
+ import { browseOnly } from "./holo-media-item.mjs";
10
+
11
+ const API = "https://api4.thetvdb.com/v4";
12
+ const ART = "https://artworks.thetvdb.com";
13
+ const artURL = (p) => (!p ? null : /^https?:/.test(p) ? p : ART + (p.startsWith("/") ? "" : "/") + p);
14
+
15
+ // createTvdb({ apikey, fetch, cache, now, tokenStore }) β€” fetch/cache/clock injectable.
16
+ // tokenStore: { get()->{token,exp}|null, set(t) } (browser binding persists it in localStorage).
17
+ export function createTvdb({ apikey = "", fetch: f, cache = null, now = () => Date.now(), tokenStore = null } = {}) {
18
+ const doFetch = f || (typeof fetch !== "undefined" ? fetch.bind(globalThis) : null);
19
+ if (!doFetch) throw new Error("holo-tvdb: a fetch implementation is required");
20
+ let mem = null;
21
+ const store = tokenStore || { get: () => mem, set: (t) => { mem = t; } };
22
+
23
+ // ── bearer token: login once, reuse until near expiry (TVDB JWTs live ~1 month) ────────────────────────
24
+ async function login() {
25
+ const res = await doFetch(API + "/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ apikey }) });
26
+ if (!res.ok) throw new Error("tvdb login " + res.status);
27
+ const j = await res.json();
28
+ const token = j && j.data && j.data.token;
29
+ if (!token) throw new Error("tvdb login: no token");
30
+ let exp = now() + 25 * 864e5; // JWT exp claim wins; fallback 25 days
31
+ try { exp = Math.min(exp, JSON.parse(atob(token.split(".")[1])).exp * 1000); } catch {}
32
+ const t = { token, exp };
33
+ await store.set(t);
34
+ return t;
35
+ }
36
+ async function token(force = false) {
37
+ const t = !force && (await store.get());
38
+ if (t && t.token && t.exp - now() > 864e5) return t.token; // >1 day left β†’ reuse
39
+ return (await login()).token;
40
+ }
41
+ async function raw(path) {
42
+ let tk = await token();
43
+ let res = await doFetch(API + path, { headers: { Authorization: "Bearer " + tk } });
44
+ if (res.status === 401) { tk = await token(true); res = await doFetch(API + path, { headers: { Authorization: "Bearer " + tk } }); }
45
+ if (!res.ok) throw new Error("tvdb " + res.status + " " + path);
46
+ return (await res.json()).data;
47
+ }
48
+ // every call goes through the ΞΊ-cache; bucket:"day" stamps the key with today so airing data self-refreshes.
49
+ const day = () => new Date(now()).toISOString().slice(0, 10);
50
+ async function api(path, { bucket = null } = {}) {
51
+ const key = "tvdb|" + path + (bucket === "day" ? "|d:" + day() : "");
52
+ if (!cache) return raw(path);
53
+ const { body } = await cache.through(key, () => raw(path));
54
+ return body;
55
+ }
56
+
57
+ // ── identity bridge: IMDb/TMDb id β†’ TVDB series id (permanent β†’ ΞΊ-cached forever) ─────────────────────
58
+ async function seriesIdByRemote(remoteId) {
59
+ if (!remoteId) return null;
60
+ const d = await api("/search/remoteid/" + encodeURIComponent(String(remoteId)));
61
+ for (const hit of d || []) if (hit && hit.series && hit.series.id) return hit.series.id;
62
+ return null;
63
+ }
64
+
65
+ // one series, extended β€” status + airing + the full default-order episode list.
66
+ async function series(tvdbId) {
67
+ const d = await api(`/series/${tvdbId}/extended?meta=episodes&short=true`, { bucket: "day" });
68
+ if (!d) return null;
69
+ const eps = (d.episodes || []).filter((e) => e && e.seasonNumber > 0);
70
+ const today = day();
71
+ const next = d.nextAired ? eps.find((e) => e.aired === d.nextAired) : null;
72
+ return {
73
+ tvdbId: d.id, name: d.name || "", status: (d.status && d.status.name) || "",
74
+ nextAired: d.nextAired || "", lastAired: d.lastAired || "",
75
+ nextEpisode: next ? { name: next.name || "", seasonNumber: next.seasonNumber, episodeNumber: next.number, aired: next.aired } : null,
76
+ upcoming: eps.filter((e) => e.aired && e.aired >= today).length,
77
+ episodes: eps,
78
+ };
79
+ }
80
+
81
+ // a season's episodes in the player's ONE episode shape (drop-in for the TMDb season path).
82
+ function normalizeEpisodes(s, seasonNumber, ctx = {}) {
83
+ return (s.episodes || []).filter((e) => e.seasonNumber === seasonNumber).map((e) => ({
84
+ id: "tvdb:ep:" + s.tvdbId + ":" + e.seasonNumber + ":" + e.number,
85
+ tvdbId: e.id, kind: "episode",
86
+ name: e.name || `Episode ${e.number}`,
87
+ seriesId: ctx.id || "tvdb:series:" + s.tvdbId, seriesName: ctx.name || s.name,
88
+ parentId: "tvdb:season:" + s.tvdbId + ":" + e.seasonNumber,
89
+ seasonNumber: e.seasonNumber, episodeNumber: e.number,
90
+ overview: e.overview || "", blurb: e.overview || "", rating: null,
91
+ runtimeSec: e.runtime ? e.runtime * 60 : 0, releaseDate: e.aired || "",
92
+ posterUrl: artURL(e.image) || ctx.posterUrl || null, backdrop: ctx.backdrop || null,
93
+ topics: ctx.topics || [], genres: ctx.genres || [],
94
+ channel: "TheTVDB", quality: ctx.quality ?? 0.7, license: "",
95
+ source: "tvdb", provider: "tvdb", kappa: "", holoKappa: "tvdb:ep:" + s.tvdbId + ":" + e.seasonNumber + ":" + e.number,
96
+ availability: browseOnly(),
97
+ }));
98
+ }
99
+
100
+ // airingOf(item) β€” the magic line for a series card: resolves the item's IMDb/TMDb id to TVDB and
101
+ // answers { status, nextAired, nextEpisode } or null. Fully ΞΊ-cached: id-map forever, series daily.
102
+ async function airingOf(it) {
103
+ if (!it || (it.kind !== "series" && it.kind !== "tv")) return null;
104
+ let tid = it.tvdbId || null;
105
+ if (!tid && it.imdbId) tid = await seriesIdByRemote(it.imdbId).catch(() => null);
106
+ if (!tid && it.tmdbId) tid = await seriesIdByRemote(it.tmdbId).catch(() => null);
107
+ if (!tid) return null;
108
+ const s = await series(tid);
109
+ return s && { tvdbId: tid, status: s.status, nextAired: s.nextAired, nextEpisode: s.nextEpisode, upcoming: s.upcoming };
110
+ }
111
+
112
+ async function seasonEpisodes(it, seasonNumber) {
113
+ const a = await airingOf({ ...it, kind: "series" });
114
+ if (!a) return [];
115
+ const s = await series(a.tvdbId);
116
+ return s ? normalizeEpisodes(s, seasonNumber, it) : [];
117
+ }
118
+
119
+ return { login, token, series, seriesIdByRemote, normalizeEpisodes, airingOf, seasonEpisodes };
120
+ }
121
+
122
+ // Built-in community key β€” series airing intel works ZERO-SETUP for every visitor (quota: 100M hits/day;
123
+ // answers are ΞΊ-cached so real traffic is a sliver of that).
124
+ const BUILTIN_KEY = "935f2887-1087-4494-9071-d90d0bafaf43";
125
+
126
+ // browser binding β€” real fetch + the persistent ΞΊ-cache + token parked in localStorage across sessions.
127
+ if (typeof window !== "undefined") {
128
+ const lsTokenStore = {
129
+ get() { try { return JSON.parse(localStorage.getItem("holoplayer.tvdb.token") || "null"); } catch { return null; } },
130
+ set(t) { try { localStorage.setItem("holoplayer.tvdb.token", JSON.stringify(t)); } catch {} },
131
+ };
132
+ window.HoloTvdb = {
133
+ createTvdb,
134
+ live(opts = {}) {
135
+ const cache = window.HoloMediaCache ? window.HoloMediaCache.live() : null;
136
+ return createTvdb({ apikey: opts.apikey || BUILTIN_KEY, cache, tokenStore: lsTokenStore });
137
+ },
138
+ };
139
+ }
140
+
141
+ export default { createTvdb };