Repository navigation
Expand file tree
/
Copy pathapi.js
More file actions
754 lines (670 loc) · 34.1 KB
/
Copy pathapi.js
File metadata and controls
754 lines (670 loc) · 34.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
/**
* Eclipse API Service
* Handles all external metadata fetching (TMDB for Movies/Series, Jikan for Anime).
*/
// ============================================================================
// CONFIGURATION
// ============================================================================
const TMDB_API_KEY = '15d2ea6d0dc1d476efbca3eba2b9bbfb'; // Real TMDB v3 API key
const TMDB_ENDPOINTS = ['https://api.tmdb.org/3', 'https://api.themoviedb.org/3'];
const TMDB_IMG_BASE = 'https://image.tmdb.org/t/p/w500'; // For posters
const TMDB_IMG_ORIGINAL = 'https://image.tmdb.org/t/p/original'; // For backgrounds/banners
const JIKAN_BASE_URL = 'https://api.jikan.moe/v4';
const proxyFetchJson = async (url) => {
// First attempt direct fetch with short 2.5s timeout
try {
const directRes = await fetch(url, { signal: AbortSignal.timeout(2500) });
if (directRes.ok) return await directRes.json();
} catch(e) {}
const proxies = [
'https://api.allorigins.win/get?url=',
'https://corsproxy.io/?',
'https://api.codetabs.com/v1/proxy?quest=',
'https://thingproxy.freeboard.io/fetch/'
];
for (let proxy of proxies) {
try {
const proxyUrl = proxy.includes('allorigins') || proxy.includes('codetabs') ? proxy + encodeURIComponent(url) : proxy + url;
const res = await fetch(proxyUrl, { signal: AbortSignal.timeout(6000) });
if (!res.ok) continue;
if (proxy.includes('allorigins')) {
const text = await res.text();
try {
const data = JSON.parse(text);
if (data && data.contents) return JSON.parse(data.contents);
} catch(e) { continue; }
} else {
return await res.json();
}
} catch (e) {
continue;
}
}
console.error('All proxies failed for:', url);
return null;
};
const FALLBACK_POSTER = 'https://placehold.co/400x600/1a1a2e/ffffff?text=No+Image';
// Helper to safely load images
const proxyImage = (url) => {
if (!url) return FALLBACK_POSTER;
return url;
};
// ============================================================================
// TMDB (Movies & Series)
// ============================================================================
const tmdbFetch = async (endpoint, params = {}) => {
if (TMDB_API_KEY === 'YOUR_TMDB_API_KEY_HERE' || !TMDB_API_KEY) {
console.warn("TMDB API Key is missing. Using mock response.");
return null;
}
const queryParams = new URLSearchParams({
api_key: TMDB_API_KEY,
...params
});
let lastError;
for (const baseUrl of TMDB_ENDPOINTS) {
const url = `${baseUrl}${endpoint}?${queryParams}`;
try {
const response = await fetch(url, { signal: AbortSignal.timeout(6000) });
if (response.ok) return await response.json();
} catch (e) {
lastError = e;
}
}
console.warn("Direct TMDB fetch failed on all endpoints, attempting proxy...", lastError);
return await proxyFetchJson(`${TMDB_ENDPOINTS[0]}${endpoint}?${queryParams}`);
};
/**
* Normalizes TMDB format into our Eclipse format
*/
const normalizeTMDB = (item, type = 'movies') => {
const score = typeof item.vote_average === 'number' ? item.vote_average.toFixed(1) : 'N/A';
const overview = (item.overview || '').toLowerCase();
const titleText = (item.title || item.name || '').toLowerCase();
const originCountry = item.origin_country || [];
let genre = '';
if (originCountry.includes('KR') || titleText.includes('korean') || overview.includes('korea') || overview.includes('k-drama')) {
genre = 'K-Drama';
} else if (originCountry.includes('TH') || titleText.includes('thai') || overview.includes('thailand') || overview.includes('thai drama')) {
genre = 'Thai Drama';
} else if (overview.includes("boy's love") || overview.includes('bl ') || titleText.includes('bl ')) {
genre = 'BL Drama';
} else if (overview.includes("girl's love") || overview.includes('gl ') || titleText.includes('gl ')) {
genre = 'GL Drama';
}
return {
id: `tmdb_${item.id}`,
tmdbId: String(item.id),
title: item.title || item.name,
category: type,
poster: item.poster_path ? proxyImage(`${TMDB_IMG_BASE}${item.poster_path}`) : FALLBACK_POSTER,
backdrop: item.backdrop_path ? proxyImage(`${TMDB_IMG_ORIGINAL}${item.backdrop_path}`) : '',
rating: score,
releaseYear: (item.release_date || item.first_air_date || '').substring(0, 4),
description: item.overview || '',
genre: genre || (type === 'series' ? 'Drama' : 'Action'),
totalEpisodes: type === 'series' ? (item.number_of_episodes || '?') : 1,
episodes: type === 'series' ? (item.number_of_episodes || 0) : 1,
runtime: item.runtime || (type === 'movies' ? 120 : 0),
raw: item
};
};
export const searchTMDB = async (query, type = 'multi') => {
// type can be 'movie', 'tv', or 'multi'
const data = await tmdbFetch(`/search/${type}`, { query, include_adult: false });
if (!data || !data.results) return [];
return data.results
.filter(item => item.media_type === 'movie' || item.media_type === 'tv' || type !== 'multi')
.map(item => normalizeTMDB(item, item.media_type === 'tv' ? 'series' : 'movies'));
};
export const discoverTMDB = async (type = 'tv', params = {}) => {
const data = await tmdbFetch(`/discover/${type}`, params);
if (!data || !data.results) return [];
return data.results.map(item => normalizeTMDB(item, type === 'tv' ? 'series' : 'movies'));
};
export const getTrendingTMDB = async (type = 'all', timeWindow = 'day', page = 1) => {
const data = await tmdbFetch(`/trending/${type}/${timeWindow}`, { page });
if (!data || !data.results || data.results.length === 0) {
const tvmaze = await fetchTVMazeSeries(0);
return tvmaze;
}
return data.results.map(item => normalizeTMDB(item, item.media_type === 'tv' ? 'series' : 'movies'));
};
// ============================================================================
// JIKAN (Anime & Manga)
// ============================================================================
// Jikan does not require an API key, but is rate-limited (3 requests per second)
let jikanQueue = [];
let isProcessingJikan = false;
let lastJikanCall = 0;
const JIKAN_DELAY = 334;
const processJikanQueue = async () => {
if (isProcessingJikan || jikanQueue.length === 0) return;
isProcessingJikan = true;
while (jikanQueue.length > 0) {
const now = Date.now();
const timeSinceLast = now - lastJikanCall;
if (timeSinceLast < JIKAN_DELAY) {
await new Promise(r => setTimeout(r, JIKAN_DELAY - timeSinceLast));
}
lastJikanCall = Date.now();
const task = jikanQueue.shift();
await task();
}
isProcessingJikan = false;
};
const jikanFetch = async (endpoint, params = {}, retries = 2) => {
return new Promise((resolve) => {
jikanQueue.push(async () => {
const queryParams = new URLSearchParams(params);
const url = queryParams.toString() ? `${JIKAN_BASE_URL}${endpoint}?${queryParams}` : `${JIKAN_BASE_URL}${endpoint}`;
try {
const response = await fetch(url, { signal: AbortSignal.timeout(6000) });
if (response.status === 429 && retries > 0) {
const retryAfter = response.headers.get('Retry-After') || 1;
await new Promise(r => setTimeout(r, retryAfter * 1000));
resolve(await jikanFetch(endpoint, params, retries - 1));
return;
}
if (!response.ok) throw new Error('Jikan Fetch Error');
resolve(await response.json());
} catch (error) {
console.warn("Direct Jikan fetch failed, attempting proxy...", error);
resolve(await proxyFetchJson(url));
}
});
processJikanQueue();
});
};
/**
* Normalizes Jikan format into our Eclipse format
*/
const normalizeJikan = (item, category = 'anime') => {
const score = typeof item.score === 'number' ? item.score.toFixed(1) : 'N/A';
const isManhwaOrManga = category === 'manhwa' || category === 'manga';
const genres = item.genres?.map(g => g.name) || item.themes?.map(g => g.name) || [];
const genre = genres[0] || (isManhwaOrManga ? 'Manhwa' : 'Action');
return {
id: `mal_${item.mal_id}`,
title: item.title_english || item.title,
category: category,
genre: genre,
poster: proxyImage(item.images?.jpg?.large_image_url || item.images?.jpg?.image_url),
backdrop: proxyImage(item.trailer?.images?.maximum_image_url || ''),
rating: score,
releaseYear: item.year || (item.aired?.prop?.from?.year) || (item.published?.prop?.from?.year) || '',
description: item.synopsis || '',
// Use 'chapters' for manhwa/manga, 'totalEpisodes' for anime
totalEpisodes: isManhwaOrManga ? undefined : (item.episodes || '?'),
chapters: isManhwaOrManga ? (item.chapters || '?') : undefined,
episodes: isManhwaOrManga ? undefined : (item.episodes || 0),
raw: item
};
};
export const searchJikan = async (query, type = 'anime') => {
// type can be 'anime' or 'manga'
const data = await jikanFetch(`/${type}`, { q: query, sfw: true });
if (!data || !data.data) return [];
// Map 'manga' search results to category 'manhwa' so they appear under right genre in the app
const category = type === 'manga' ? 'manhwa' : type;
return data.data.map(item => normalizeJikan(item, category));
};
const STATIC_TOP_ANIME = [
{ id: 'an_1', title: 'Jujutsu Kaisen S2', category: 'anime', genre: 'Action', poster: 'https://cdn.myanimelist.net/images/anime/1792/138022.jpg', rating: '8.8', releaseYear: '2023', description: 'Sorcerers battle high-grade curses in Shibuya.', totalEpisodes: 23, episodes: 23 },
{ id: 'an_2', title: 'Attack on Titan Final Season', category: 'anime', genre: 'Action', poster: 'https://cdn.myanimelist.net/images/anime/1000/110531.jpg', rating: '9.1', releaseYear: '2022', description: 'Eren Yeager leads the ultimate conflict for Eldian freedom.', totalEpisodes: 28, episodes: 28 },
{ id: 'an_3', title: 'Frieren: Beyond Journey\'s End', category: 'anime', genre: 'Fantasy', poster: 'https://cdn.myanimelist.net/images/anime/1015/138025.jpg', rating: '9.3', releaseYear: '2023', description: 'An elven mage embarks on a journey to reflect on human life and memories.', totalEpisodes: 28, episodes: 28 },
{ id: 'an_4', title: 'Solo Leveling (Arise)', category: 'anime', genre: 'Action', poster: 'https://uploads.mangadex.org/covers/32d76d19-8a05-4db0-9fc2-e0b0648464d0/d2449bdf-87db-452a-929a-fb08e6e58b1f.jpg.512.jpg', rating: '8.7', releaseYear: '2024', description: 'Sung Jinwoo awakens as the Shadow Monarch in an apocalyptic hunter world.', totalEpisodes: 12, episodes: 12 },
{ id: 'an_5', title: 'Demon Slayer S4', category: 'anime', genre: 'Action', poster: 'https://cdn.myanimelist.net/images/anime/1242/141381.jpg', rating: '8.9', releaseYear: '2024', description: 'The Hashira train Tanjiro and the Demon Slayers for the final war.', totalEpisodes: 8, episodes: 8 },
{ id: 'an_6', title: 'Chainsaw Man', category: 'anime', genre: 'Action', poster: 'https://cdn.myanimelist.net/images/anime/1806/126216.jpg', rating: '8.8', releaseYear: '2022', description: 'Denji merges with Pochita to become Chainsaw Man.', totalEpisodes: 12, episodes: 12 },
{ id: 'an_7', title: 'Bleach: Thousand-Year Blood War', category: 'anime', genre: 'Action', poster: 'https://cdn.myanimelist.net/images/anime/1764/126627.jpg', rating: '9.0', releaseYear: '2022', description: 'The Soul Society is attacked by the Quincy King Yhwach.', totalEpisodes: 26, episodes: 26 },
{ id: 'an_8', title: 'Blue Lock', category: 'anime', genre: 'Sports', poster: 'https://cdn.myanimelist.net/images/anime/1258/126929.jpg', rating: '8.4', releaseYear: '2022', description: '300 strikers compete in an intense facility to become Japan\'s top goalscorer.', totalEpisodes: 24, episodes: 24 }
];
export const getTopJikan = async (type = 'anime', page = 1) => {
try {
const fetchPromise = (async () => {
const data = await jikanFetch(`/top/${type}`, { page });
if (data && data.data && data.data.length > 0) {
return data.data.slice(0, 20).map(item => normalizeJikan(item, type));
}
return [];
})();
const timeoutPromise = new Promise(resolve => setTimeout(() => resolve([]), 3500));
const results = await Promise.race([fetchPromise, timeoutPromise]);
if (results && results.length > 0) return results;
} catch (e) {
console.warn(`Live top ${type} fetch fallback active:`, e);
}
return type === 'anime' ? STATIC_TOP_ANIME : [];
};
// ============================================================================
// TVMAZE (Zero-key fallback for Movies & TV Series)
// ============================================================================
export const fetchTVMazeSeries = async (page = 0) => {
try {
const res = await fetch(`https://api.tvmaze.com/shows?page=${page}`, { signal: AbortSignal.timeout(6000) });
if (!res.ok) return [];
const data = await res.json();
return data.slice(0, 50).map(item => {
const isMovie = item.runtime && item.runtime >= 90 && item.summary && item.summary.toLowerCase().includes('movie');
const category = isMovie ? 'movies' : 'series';
return {
id: `tvmaze_${item.id}`,
title: item.name,
category: category,
poster: proxyImage(item.image?.original || item.image?.medium),
backdrop: proxyImage(item.image?.original),
rating: item.rating?.average ? item.rating.average.toFixed(1) : '8.2',
releaseYear: (item.premiered || '').substring(0, 4),
genre: item.genres && item.genres[0] ? item.genres[0] : (isMovie ? 'Sci-Fi' : 'Drama'),
description: item.summary ? item.summary.replace(/<[^>]+>/g, '') : '',
episodes: category === 'series' ? 12 : 1,
runtime: item.runtime || 120
};
});
} catch (e) {
console.error('TVMaze fetch error:', e);
return [];
}
};
const STATIC_TOP_MANHWA = [
{ id: 'mh_1', title: 'Solo Leveling', category: 'manhwa', genre: 'Action', poster: 'https://uploads.mangadex.org/covers/32d76d19-8a05-4db0-9fc2-e0b0648464d0/d2449bdf-87db-452a-929a-fb08e6e58b1f.jpg.512.jpg', rating: '9.8', releaseYear: '2018', description: 'In a world where hunters must battle deadly monsters, Sung Jinwoo is weak until a mysterious quest grants him limitless leveling powers.', chapters: 200 },
{ id: 'mh_2', title: 'Omniscient Reader\'s Viewpoint', category: 'manhwa', genre: 'Fantasy', poster: 'https://uploads.mangadex.org/covers/e005080e-3b56-4dc5-b461-750d686f082e/6b010c2c-e11f-49e0-88cb-7e23b2c4e2cf.jpg.512.jpg', rating: '9.7', releaseYear: '2020', description: 'Dokja was an ordinary reader of an web novel. When the novel suddenly becomes reality, he is the only one who knows how the world ends.', chapters: 210 },
{ id: 'mh_3', title: 'Tower of God', category: 'manhwa', genre: 'Action', poster: 'https://uploads.mangadex.org/covers/c600f681-300e-436f-b258-154df656a88b/a9c1c4e7-4958-45d2-a734-b25a3962d854.jpg.512.jpg', rating: '9.5', releaseYear: '2010', description: 'Reach the top of the Tower and everything will be yours. Bam enters the Tower to follow his friend Rachel.', chapters: 600 },
{ id: 'mh_4', title: 'The Beginning After the End', category: 'manhwa', genre: 'Fantasy', poster: 'https://uploads.mangadex.org/covers/f4a0c878-5e83-4a11-897b-edab9c1b72e5/6ea9f1e1-e18e-49b0-a39c-f9e422c54ee7.jpg.512.jpg', rating: '9.6', releaseYear: '2018', description: 'King Grey has unrivaled strength, wealth, and prestige in a world governed by martial ability. Reborn into a new world filled with magic.', chapters: 180 },
{ id: 'mh_5', title: 'Wind Breaker', category: 'manhwa', genre: 'Sports', poster: 'https://uploads.mangadex.org/covers/3522f18f-ebbc-4995-bb04-f65582f3ef0d/8e25dd05-e325-4c07-9b2f-12ee499d3e81.jpg.512.jpg', rating: '9.4', releaseYear: '2013', description: 'Jay is a high school student driven to excel in school, but his passion lies in street biking.', chapters: 450 },
{ id: 'mh_6', title: 'Eleceed', category: 'manhwa', genre: 'Action', poster: 'https://uploads.mangadex.org/covers/3f8221c9-7e05-41e9-9189-980b62854964/6b2ebed5-c0ea-46b0-99e7-d95b5832e8bb.jpg.512.jpg', rating: '9.7', releaseYear: '2018', description: 'Jiwoo is a kind-hearted young man who harnesses lightning abilities. Kayden is a secret agent hiding inside the body of a chubby cat.', chapters: 290 },
{ id: 'mh_7', title: 'Lookism', category: 'manhwa', genre: 'Drama', poster: 'https://uploads.mangadex.org/covers/6f0270a2-23f4-4113-90d5-e2f07d2c3e1e/b71a2571-081e-450f-90e1-0c58a697669d.jpg.512.jpg', rating: '9.2', releaseYear: '2014', description: 'A high school outcast wakes up one day with a brand new, strikingly handsome body.', chapters: 480 },
{ id: 'mh_8', title: 'Nanomachine', category: 'manhwa', genre: 'Martial Arts', poster: 'https://uploads.mangadex.org/covers/71eb6772-5b91-49b5-8c76-574a445e9988/443f11bc-993d-4c38-89c0-9a3d463e2324.jpg.512.jpg', rating: '9.5', releaseYear: '2020', description: 'An illegitimate prince of the Demonic Cult receives a futuristic nanomachine injection from his descendant.', chapters: 200 }
];
export const fetchTopManhwa = async (page = 1) => {
try {
const fetchPromise = (async () => {
// Try Jikan first
const data = await jikanFetch('/top/manga', { type: 'manhwa', page });
if (data && data.data && data.data.length > 0) {
return data.data.slice(0, 20).map(item => normalizeJikan(item, 'manhwa'));
}
// Try MangaDex public API
const offset = (page - 1) * 20;
const mdUrl = `https://api.mangadex.org/manga?limit=20&offset=${offset}&publicationDemographic[]=seinen&publicationDemographic[]=josei&originalLanguage[]=ko&order[followedCount]=desc&contentRating[]=safe&includes[]=cover_art`;
const mdRes = await fetch(mdUrl, { signal: AbortSignal.timeout(3000) });
if (mdRes.ok) {
const mdData = await mdRes.json();
if (mdData && mdData.data && mdData.data.length > 0) {
return mdData.data.map(manga => {
const title = manga.attributes?.title?.en || manga.attributes?.title?.ko || Object.values(manga.attributes?.title || {})[0] || 'Unknown';
const coverRelation = manga.relationships?.find(r => r.type === 'cover_art');
const coverFilename = coverRelation?.attributes?.fileName;
const poster = coverFilename ? `https://uploads.mangadex.org/covers/${manga.id}/${coverFilename}.256.jpg` : FALLBACK_POSTER;
return {
id: `md_${manga.id}`,
title,
category: 'manhwa',
genre: 'Action',
poster,
rating: '9.4',
chapters: 150
};
}).filter(m => m.title !== 'Unknown');
}
}
return [];
})();
const timeoutPromise = new Promise(resolve => setTimeout(() => resolve([]), 3500));
const results = await Promise.race([fetchPromise, timeoutPromise]);
if (results && results.length > 0) return results;
} catch (mdErr) {
console.warn('Live manhwa fetch fallback active:', mdErr);
}
return STATIC_TOP_MANHWA;
};
export const fetchLiveCatalog = async (page = 1) => {
try {
const tvmazePage = Math.max(0, page - 1);
const tmdbPage = ((page - 1) % 3) + 1;
// Fetch base content
const [anime, manhwa, tvshows, tmdbMovies, tmdbSeries, kdrama, thai, bl, gl] = await Promise.all([
getTopJikan('anime', page),
fetchTopManhwa(page),
fetchTVMazeSeries(tvmazePage),
getTrendingTMDB('movie', 'week', tmdbPage),
getTrendingTMDB('tv', 'week', tmdbPage),
discoverTMDB('tv', { with_original_language: 'ko', with_genres: '18', sort_by: 'popularity.desc', page }),
discoverTMDB('tv', { with_original_language: 'th', with_genres: '18', sort_by: 'popularity.desc', page }),
discoverTMDB('tv', { with_keywords: '210024', sort_by: 'popularity.desc', page }),
searchTMDB('girls love', 'tv')
]);
const series = tmdbSeries && tmdbSeries.length > 0
? tmdbSeries.map(s => ({ ...s, category: 'series' })).slice(0, 20)
: tvshows.filter(s => s.category === 'series').slice(0, 20);
const movies = tmdbMovies && tmdbMovies.length > 0
? tmdbMovies.map(s => ({ ...s, category: 'movies' })).slice(0, 20)
: tvshows.filter(s => s.category === 'movies').concat(tvshows.slice(25, 45)).map(s => ({ ...s, category: 'movies' })).slice(0, 20);
return {
anime: (anime || []).slice(0, 20),
manhwa: (manhwa || []).slice(0, 20),
series: series.length > 0 ? series : [],
movies: movies.length > 0 ? movies : [],
kdrama: (kdrama || []).map(s => ({ ...s, category: 'kdrama' })).slice(0, 20),
thai: (thai || []).map(s => ({ ...s, category: 'thai' })).slice(0, 20),
bl: (bl || []).map(s => ({ ...s, category: 'bl' })).slice(0, 20),
gl: (gl || []).map(s => ({ ...s, category: 'gl' })).slice(0, 20)
};
} catch (e) {
console.error('Error fetching live catalog:', e);
return null;
}
};
// ============================================================================
// UNIFIED SEARCH EXPORT
// ============================================================================
/**
* Unified Search across TMDB, TVMaze, and Jikan.
*/
export const searchAll = async (query, categoryFilter = 'all') => {
if (!query) return [];
try {
const [tmdbResults, animeResults, mangaResults, tvmazeRes] = await Promise.all([
searchTMDB(query, 'multi'),
searchJikan(query, 'anime'),
searchJikan(query, 'manga'),
fetch(`https://api.tvmaze.com/search/shows?q=${encodeURIComponent(query)}`, { signal: AbortSignal.timeout(6000) }).then(r => r.ok ? r.json() : []).catch(() => [])
]);
const tvmazeResults = (tvmazeRes || []).map(item => ({
id: `tvm_${item.show.id}`,
title: item.show.name,
category: item.show.type === 'Animation' ? 'anime' : 'series',
poster: item.show.image?.medium || item.show.image?.original || FALLBACK_POSTER,
rating: item.show.rating?.average ? item.show.rating.average.toFixed(1) : '8.0',
releaseYear: (item.show.premiered || '').substring(0, 4),
genre: item.show.genres?.[0] || 'Drama',
description: item.show.summary ? item.show.summary.replace(/<[^>]+>/g, '') : ''
}));
const all = [...tmdbResults, ...animeResults, ...mangaResults, ...tvmazeResults];
// Deduplicate logic
const unique = [];
const seen = new Set();
for (const item of all) {
if (!item.title) continue;
const normalizedTitle = item.title.toLowerCase().replace(/[^a-z0-9]/g, '');
if (!seen.has(normalizedTitle)) {
const duplicates = all.filter(t => t.title && t.title.toLowerCase().replace(/[^a-z0-9]/g, '') === normalizedTitle);
duplicates.sort((a, b) => {
const aHasPoster = a.poster !== FALLBACK_POSTER ? 1 : 0;
const bHasPoster = b.poster !== FALLBACK_POSTER ? 1 : 0;
if (aHasPoster !== bHasPoster) return bHasPoster - aHasPoster;
const aHasDesc = a.description && a.description.length > 10 ? 1 : 0;
const bHasDesc = b.description && b.description.length > 10 ? 1 : 0;
return bHasDesc - aHasDesc;
});
unique.push(duplicates[0]);
seen.add(normalizedTitle);
}
}
if (categoryFilter !== 'all') {
const target = categoryFilter.toLowerCase();
return unique.filter(t => {
if (t.category === target) return true;
const g = (t.genre || '').toLowerCase();
const d = (t.description || '').toLowerCase();
const titleLower = (t.title || '').toLowerCase();
if (target === 'kdrama' && (g.includes('k-drama') || d.includes('korea') || titleLower.includes('korea'))) return true;
if (target === 'thai' && (g.includes('thai') || d.includes('thailand') || titleLower.includes('thai'))) return true;
if (target === 'bl' && (g.includes('bl') || d.includes("boy's love") || titleLower.includes('bl'))) return true;
if (target === 'gl' && (g.includes('gl') || d.includes("girl's love") || titleLower.includes('gl'))) return true;
return false;
});
}
return unique;
} catch(e) {
console.error('searchAll Error:', e);
return [];
}
};
// ============================================================================
// STREAMING SEARCH — fires callback as each source responds (no waiting for all)
// ============================================================================
export const searchAllStreaming = (query, categoryFilter = 'all', onResult) => {
if (!query) return Promise.resolve();
const seen = new Set();
const normalize = (item) => {
if (!item || !item.title) return null;
const key = item.title.toLowerCase().replace(/[^a-z0-9]/g, '');
if (seen.has(key)) return null;
seen.add(key);
if (categoryFilter !== 'all' && item.category !== categoryFilter) return null;
return item;
};
const emit = async (items) => {
for (const item of (items || [])) {
const clean = normalize(item);
if (clean) {
onResult(clean);
await new Promise(r => setTimeout(r, 40));
}
}
};
// TVMaze — usually fastest, no rate limit
const tvmazeP = fetch(
`https://api.tvmaze.com/search/shows?q=${encodeURIComponent(query)}`,
{ signal: AbortSignal.timeout(5000) }
).then(r => r.ok ? r.json() : []).then(res => {
emit((res || []).map(item => ({
id: `tvm_${item.show.id}`,
title: item.show.name,
category: item.show.type === 'Animation' ? 'anime' : 'series',
poster: item.show.image?.medium || item.show.image?.original || FALLBACK_POSTER,
rating: item.show.rating?.average ? item.show.rating.average.toFixed(1) : '8.0',
releaseYear: (item.show.premiered || '').substring(0, 4),
genre: item.show.genres?.[0] || 'Drama',
description: item.show.summary ? item.show.summary.replace(/<[^>]+>/g, '') : ''
})));
}).catch(() => {});
// TMDB — needs key but fast
const tmdbP = searchTMDB(query, 'multi').then(res => emit(res)).catch(() => {});
// Jikan anime
const animeP = searchJikan(query, 'anime').then(res => emit(res)).catch(() => {});
// Jikan manga/manhwa
const mangaP = searchJikan(query, 'manga').then(res => emit(res)).catch(() => {});
return Promise.allSettled([tvmazeP, tmdbP, animeP, mangaP]);
};
export const getTrendingAll = async () => {
const [tmdbTrending, animeTrending] = await Promise.all([
getTrendingTMDB('all', 'day'),
getTopJikan('anime')
]);
return [...tmdbTrending, ...animeTrending];
};
// ============================================================================
// EXTENDED DETAILS (CAST, EPISODES)
// ============================================================================
export const fetchJikanCharacters = async (malId) => {
const data = await jikanFetch(`/anime/${malId}/characters`);
if (!data || !data.data) return [];
// return top 6 voice actors (or characters)
return data.data.slice(0, 6).map(c => {
const va = c.voice_actors?.find(v => v.language === 'Japanese');
return {
name: va ? va.person.name : c.character.name,
role: c.character.name,
image: proxyImage(va ? va.person.images?.jpg?.image_url : c.character.images?.jpg?.image_url)
};
});
};
export const fetchTVMazeCast = async (tvmazeId) => {
try {
const res = await fetch(`https://api.tvmaze.com/shows/${tvmazeId}/cast`, { signal: AbortSignal.timeout(6000) });
if (!res.ok) return [];
const data = await res.json();
return data.slice(0, 6).map(c => ({
name: c.person.name,
role: c.character.name,
image: proxyImage(c.person.image?.medium)
}));
} catch (e) {
return [];
}
};
export const fetchTMDBCast = async (tmdbId, category = 'movies', titleName = '') => {
const catLower = (category || '').toLowerCase();
const isTv = ['series', 'kdrama', 'bl', 'gl', 'thai', 'tv', 'anime'].includes(catLower);
const type = isTv ? 'tv' : 'movie';
let data = await tmdbFetch(`/${type}/${tmdbId}/credits`);
if ((!data || !data.cast || data.cast.length === 0)) {
const oppType = isTv ? 'movie' : 'tv';
data = await tmdbFetch(`/${oppType}/${tmdbId}/credits`);
}
if (data && data.cast && data.cast.length > 0) {
return data.cast.slice(0, 10).map(c => ({
name: c.name,
role: c.character || c.known_for_department || 'Actor',
image: c.profile_path ? `${TMDB_IMG_BASE}${c.profile_path}` : FALLBACK_POSTER
}));
}
// TVMaze fallback if TMDB fails
if (titleName) {
try {
const searchRes = await fetch(`https://api.tvmaze.com/search/shows?q=${encodeURIComponent(titleName)}`, { signal: AbortSignal.timeout(5000) });
if (searchRes.ok) {
const searchData = await searchRes.json();
if (searchData && searchData.length > 0) {
const tvmazeId = searchData[0].show.id;
return await fetchTVMazeCast(tvmazeId);
}
}
} catch(e) {
console.error('TVMaze fallback error:', e);
}
}
return [];
};
export const fetchTitleDetails = async (id, category) => {
try {
if (String(id).startsWith('tmdb_')) {
const tmdbId = String(id).replace('tmdb_', '');
const type = category === 'series' ? 'tv' : 'movie';
const data = await tmdbFetch(`/${type}/${tmdbId}`);
if (data) {
return {
totalEpisodes: type === 'tv' ? (data.number_of_episodes || '?') : 1,
totalSeasons: type === 'tv' ? (data.number_of_seasons || 1) : 1,
runtime: data.runtime || 120
};
}
} else if (String(id).startsWith('mal_')) {
const malId = String(id).replace('mal_', '');
const data = await jikanFetch(`/anime/${malId}`);
if (data && data.data) {
return { totalEpisodes: data.data.episodes || '?' };
}
}
} catch (e) {
console.error('Failed to fetch detailed info:', e);
}
return null;
};
// ============================================================================
// UNIVERSAL TMDB ID RESOLVER (Converts MAL / TVMaze / MangaDex -> TMDB ID)
// ============================================================================
export const resolveTMDBId = async (media) => {
if (!media) return null;
if (media.tmdbId) return media.tmdbId;
const rawId = String(media.id || '');
if (rawId.startsWith('tmdb_')) {
const parsed = rawId.replace('tmdb_', '');
if (!isNaN(parseInt(parsed))) {
media.tmdbId = parsed;
return parsed;
}
}
if (/^\d+$/.test(rawId)) {
media.tmdbId = rawId;
return rawId;
}
try {
const cleanTitle = (media.title || '').split('(')[0].split('Season')[0].trim();
if (!cleanTitle) return null;
const data = await tmdbFetch('/search/multi', { query: cleanTitle, include_adult: false });
if (data && data.results && data.results.length > 0) {
const match = data.results.find(r => (r.title || r.name || '').toLowerCase() === cleanTitle.toLowerCase()) || data.results[0];
if (match && match.id) {
media.tmdbId = String(match.id);
return String(match.id);
}
}
} catch (e) {
console.error('Failed to resolve TMDB ID:', e);
}
return null;
};
// ============================================================================
// STREAMING PROVIDERS
// ============================================================================
export const getEmbedUrl = (media, provider, season = 1, episode = 1) => {
let tmdbId = media.tmdbId || null;
let category = media.category;
if (!tmdbId && String(media.id).startsWith('tmdb_')) {
tmdbId = String(media.id).replace('tmdb_', '');
}
if (!tmdbId) {
const numericMatch = String(media.id).match(/\d+/);
tmdbId = numericMatch ? numericMatch[0] : media.id;
}
const type = category === 'movies' ? 'movie' : 'tv';
switch (provider) {
case 'nontongo':
return type === 'movie' ?
`https://nontongo.win/embed/movie/${tmdbId}` :
`https://nontongo.win/embed/tv/${tmdbId}/${season}/${episode}`;
case 'vidlink':
return type === 'movie' ?
`https://vidlink.pro/movie/${tmdbId}` :
`https://vidlink.pro/tv/${tmdbId}/${season}/${episode}`;
case 'vidsrc-pm':
return type === 'movie' ?
`https://vidsrc.pm/embed/movie/${tmdbId}` :
`https://vidsrc.pm/embed/tv/${tmdbId}/${season}/${episode}`;
case 'vidsrc-net':
return type === 'movie' ?
`https://vidsrc.net/embed/movie?tmdb=${tmdbId}` :
`https://vidsrc.net/embed/tv?tmdb=${tmdbId}&season=${season}&episode=${episode}`;
case 'vidsrc-xyz':
return type === 'movie' ?
`https://vidsrc.xyz/embed/movie/${tmdbId}` :
`https://vidsrc.xyz/embed/tv/${tmdbId}/${season}-${episode}`;
case 'vidsrc-in':
return type === 'movie' ?
`https://vidsrc.in/embed/movie/${tmdbId}` :
`https://vidsrc.in/embed/tv/${tmdbId}/${season}/${episode}`;
case 'vidsrc-cc':
return type === 'movie' ?
`https://vidsrc.cc/v2/embed/movie/${tmdbId}` :
`https://vidsrc.cc/v2/embed/tv/${tmdbId}/${season}/${episode}`;
case 'embed-su':
return type === 'movie' ?
`https://embed.su/embed/movie/${tmdbId}` :
`https://embed.su/embed/tv/${tmdbId}/${season}/${episode}`;
case 'superembed':
return type === 'movie' ?
`https://multiembed.mov/directstream.php?video_id=${tmdbId}&tmdb=1` :
`https://multiembed.mov/directstream.php?video_id=${tmdbId}&tmdb=1&s=${season}&e=${episode}`;
case '2embed':
return type === 'movie' ?
`https://www.2embed.cc/embed/${tmdbId}` :
`https://www.2embed.cc/embedtv/${tmdbId}&s=${season}&e=${episode}`;
case 'smashy':
return type === 'movie' ?
`https://player.smashy.stream/movie/${tmdbId}` :
`https://player.smashy.stream/tv/${tmdbId}?s=${season}&e=${episode}`;
case 'autoembed':
return type === 'movie' ?
`https://autoembed.co/movie/tmdb/${tmdbId}` :
`https://autoembed.co/tv/tmdb/${tmdbId}-${season}-${episode}`;
case 'adminhihi':
return type === 'movie' ?
`https://moviesapi.club/movie/${tmdbId}` :
`https://moviesapi.club/tv/${tmdbId}-${season}-${episode}`;
default:
return `https://vidsrc.pm/embed/${type}/${tmdbId}`;
}
};