Repository navigation
Expand file tree
/
Copy pathplugin.js
More file actions
2594 lines (2384 loc) · 126 KB
/
Copy pathplugin.js
File metadata and controls
2594 lines (2384 loc) · 126 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
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// PelisYSeries (formerly Maratón; id and telemetry areas stay "maraton"): series and anime from Spanish-language streaming sites. Everything runs on the device: HTML is read with
// kino.fetch (regex, no kino.html, so the Node kit runs it too); each embed's player page is read for its own playlist
// when it carries one (no hidden browser needed), and otherwise opened in Kino's hidden browser
// (kino.browser.capture), which reports the video request the page makes.
//
// Refs: "<site>|<path>" e.g. "sk|/serie/dark" (a series), "sk|/serie/dark/temporada/1/capitulo/1" (an episode).
// Rows: "row|<site>|<kind>" with the catalog page number as the cursor.
export const UA = "Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Mobile Safari/537.36";
const SITES = {
sk: {
name: "SeriesKao",
base: "https://serieskao.top",
search: (q) => `/search?s=${encodeURIComponent(q).replace(/%2F/gi, "/")}`,
catalog: { serie: "/series?page=", anime: "/animes?page=", pelicula: "/peliculas?page=" },
episodeRx: /<a[^>]+href="([^"]*?\/temporada\/(\d+)\/capitulo\/(\d+))"[^>]*>([\s\S]*?)<\/a>/gi,
},
};
/** Every site the plugin can read, in the order results are merged ("Usar …" in Ajustes picks which ones). */
const ALL_SITES = ["sk", "ac", "sl"];
const SITE_SETTING = { sk: "useSerieskao", ac: "useAllcalidad", sl: "useSololatino" };
const SITE_NAME = { sk: "SeriesKao", ac: "AllCalidad", sl: "SoloLatino" };
/** The sites switched on in the plugin's settings (all by default; validateSettings refuses switching all off). */
export function activeSites() {
return ALL_SITES.filter((id) => configValue(SITE_SETTING[id], true) !== false);
}
/** A setting's value, or [fallback] when it is unset or kino.config is not there (an old kit). */
function configValue(key, fallback) {
try {
const v = kino.config.get(key);
return v === undefined || v === null || v === "" ? fallback : v;
} catch (_) {
return fallback;
}
}
// ---------- time: every window in one place ----------
//
// Kino gives home/section/browse 20 s, search 15 s and a browser plugin's resolve 75 s, and switches a plugin off after
// three timeouts in a row. So no list call waits for its slowest site: each answers with what is ready by its own
// deadline (a site that is late shows its last good rows), and resolve keeps its own clock well inside Kino's.
/** Tests shrink these; nothing else changes them. */
export const LIMITS = {
/** One fetch inside resolve or episodes. */
fetchMs: 10000,
/** Reading an embed's page for its playlist: they answer in about a second (measured 0.5-1.6 s). */
extractMs: 6000,
/** One fetch for home, section, browse, search or categories: a slow site must not hold the screen. */
listFetchMs: 6000,
/** home and section answer with what is ready by then (Kino allows 20 s). */
homeMs: 12000,
/** search's sites (Kino allows 15 s; a SoloLatino page read may take 12 of them). */
searchMs: 12500,
/** categories' art lookups, all of them together. */
artMs: 3000,
/** resolve: listing an episode's servers (site page, embed69, SoloLatino's player tokens), before any video search. */
serversMs: 22000,
/** resolve's own budget for starting one more attempt, under Kino's 75 s. */
resolveMs: 68000,
/** A hidden page is never opened with less than this left. */
minCaptureMs: 8000,
/** A Home row younger than this is shown without asking the site again. */
rowFreshMs: 15 * 60 * 1000,
/** How long a site's last good rows stay as the stand-in when it is down. */
rowKeepMs: 3 * 86400 * 1000,
/** A site that failed this many list reads in a row rests for [breakerMs]: only its saved rows are shown. */
breakerFails: 3,
breakerMs: 5 * 60 * 1000,
};
/**
* Waits [ms] with kino.sleep (there is no setTimeout in Kino's engine). [cancel] stops it within one short step:
* Kino ends a call only once every pending kino.sleep is over, so a timer left running after its race was decided
* held the whole answer back (measured 2026-10-04 on a Redmi: the video found in 4.5 s, resolve answered at 22 s).
*/
async function sleepFor(ms, cancel = null) {
let left = Math.max(0, Math.floor(ms));
while (left > 0 && !(cancel && cancel.done)) {
const step = Math.min(cancel ? SLEEP_STEP_MS : 5000, left);
await kino.sleep(step);
left -= step;
}
}
/** How late a cancelled timer may still keep a call open. */
const SLEEP_STEP_MS = 250;
/**
* [p]'s value, or [fallback] once [ms] passed. A late rejection of [p] is handled here: one nobody listens to would
* fail the whole call ("una llamada a Kino falló sin decir por qué"). The timer stops as soon as the race is decided.
*/
export async function within(p, ms, fallback = null) {
const guarded = Promise.resolve(p).then((v) => ({ v }), (e) => ({ e }));
if (ms <= 0) return fallback;
const cancel = { done: false };
const timer = sleepFor(ms, cancel).then(() => null, () => null);
try {
const r = await Promise.race([guarded, timer]);
if (!r) return fallback;
if (r.e) throw r.e;
return r.v;
} finally {
cancel.done = true;
}
}
// ---------- site health: a site that keeps failing rests a few minutes ----------
const healthKey = (siteId) => "health:" + siteId;
/** True while [siteId] rests after [LIMITS.breakerFails] failures in a row: list calls do not ask it at all. */
export function siteResting(siteId, nowMs = Date.now()) {
const h = storageGet(healthKey(siteId));
return !!h && (h.until || 0) > nowMs;
}
function siteFailed(siteId, code) {
const h = storageGet(healthKey(siteId)) || {};
const fails = (h.fails || 0) + 1;
if (fails >= LIMITS.breakerFails) {
kino.log(`site ${siteId}: ${fails} failures in a row (${code}), resting ${Math.round(LIMITS.breakerMs / 1000)} s`);
storageSet(healthKey(siteId), { fails: 0, until: Date.now() + LIMITS.breakerMs }, LIMITS.breakerMs + 60000);
} else {
storageSet(healthKey(siteId), { fails, until: h.until || 0 }, 3600 * 1000);
}
}
function siteAnswered(siteId) {
if (storageGet(healthKey(siteId))) storageRemove(healthKey(siteId));
}
function restingError(siteId) {
return kino.error("unavailable", `${SITE_NAME[siteId]}: en pausa tras varias fallas`, {
userMessage: `${SITE_NAME[siteId]} no está respondiendo; se vuelve a probar en unos minutos.`,
});
}
/**
* One request to a site's own host. `list` marks a Home/section/browse/search/categories read: it is refused at once
* while the site rests and gets the short timeout. A timeout, a network error or a 5xx/429 counts towards resting;
* any 2xx clears it. Network failures become a typed "unavailable" with a sentence for the person.
*/
async function siteFetch(siteId, url, init = {}, { list = false } = {}) {
await null;
if (list && siteResting(siteId)) throw restingError(siteId);
const t0 = Date.now();
let r;
try {
r = await kino.fetch(url, { ...init, timeoutMs: init.timeoutMs || (list ? LIMITS.listFetchMs : LIMITS.fetchMs) });
} catch (e) {
kino.log(`fetch ${safe(url)}: ${e.code || ""} ${e.message} after ${Date.now() - t0} ms`);
if (e.code === "host_not_allowed" || e.code === "invalid_request") {
throw kino.error("unavailable", `${SITE_NAME[siteId]}: ${e.code}`, { userMessage: `${SITE_NAME[siteId]} no se pudo consultar desde aquí.` });
}
siteFailed(siteId, e.code || "network");
report("maraton:site", "down", `site=${siteId}`, `code=${e.code || "network"}`, `ms=${Date.now() - t0}`);
throw kino.error("unavailable", `${SITE_NAME[siteId]}: ${e.code || "network"}`, { userMessage: `${SITE_NAME[siteId]} no responde. Vuelve a intentar en un rato.` });
}
kino.log(`fetch ${safe(url)} -> ${r.status} in ${Date.now() - t0} ms`);
if (r.ok) siteAnswered(siteId);
else if (r.status >= 500 || r.status === 429) siteFailed(siteId, `http${r.status}`);
return r;
}
// ---------- allcalidad (a JSON API, no HTML) ----------
//
// allcalidad.re's front end (measured 2026-10-04 evening, after it left its old tmdb.* API) reads everything from
// /api/rest: `search`, `listing` (a type, or a genre), `episodes?post_id=` and `player?post_id=`, whose `embeds` are
// `{ lang, quality, url }`. Titles are WordPress posts: `_id`, `slug`, `type` (movies / tvshows / animes), images
// under /wp-content/uploads. A post carries no TMDB id; an episode does (`show_id`), and so does the `videoapp.zip`
// embed (`/e/movie/<tmdb>`, `/e/tv/<tmdb>/<s>/<e>`), which old refs (`ac|movie/603`, `ac|tvshow/70523/1/2`) still play.
const AC = {
id: "ac", name: "AllCalidad", base: "https://allcalidad.re", api: "https://allcalidad.re/api/rest",
uploads: "https://allcalidad.re/wp-content/uploads", tmdbImg: "https://image.tmdb.org/t/p/",
};
const AC_TYPES = { movies: "movie", tvshows: "series", animes: "series" };
/** The site's genre ids (window.siteConfig.datas.genres), named the way Kino shows genres. */
const AC_GENRES = {
26: "Acción", 677: "Acción y aventura", 51: "Animación", 25: "Aventura", 157: "Bélica", 27: "Ciencia ficción", 216: "Comedia",
135: "Crimen", 4657: "Documental", 156: "Drama", 52: "Familia", 109: "Fantasía", 429: "Historia", 695: "Infantil",
273: "Misterio", 332: "Música", 6357: "Película de TV", 11125: "Reality", 239: "Romance", 658: "Ciencia ficción y fantasía",
17921: "Telenovela", 295: "Suspenso", 447: "Terror", 755: "Guerra y política", 182: "Western",
};
const VIDEOAPP = "https://videoapp.zip";
/** One API answer's `data`; `{ error: true }` answers are typed errors, never parsed as content. */
async function acGet(path, { list = false } = {}) {
const r = await siteFetch(AC.id, AC.api + path, { headers: { "User-Agent": UA, Accept: "application/json", Referer: AC.base + "/" } }, { list });
if (!r.ok) {
if (r.status !== 404) report("maraton:site", "http", "site=ac", `status=${r.status}`);
throw kino.error(r.status === 404 ? "not_found" : r.status === 429 ? "rate_limited" : "unavailable", `${AC.name} respondió ${r.status}`,
{ userMessage: r.status === 404 ? `${AC.name} ya no tiene este título.` : `${AC.name} no está respondiendo bien (${r.status}).` });
}
let body;
try {
body = r.json();
} catch (_) {
siteFailed(AC.id, "not_json");
report("maraton:site", "not_json", "site=ac");
throw kino.error("unavailable", `${AC.name}: respuesta que no es JSON`, { userMessage: `${AC.name} respondió algo que no se entiende.` });
}
if (!body || body.error) {
const msg = String((body && body.message) || "error").slice(0, 60);
throw kino.error(msg === "404" ? "not_found" : "unavailable", `${AC.name}: ${msg}`,
{ userMessage: msg === "404" ? `${AC.name} ya no tiene este título.` : `${AC.name} no está respondiendo bien.` });
}
return body.data;
}
const acImage = (p) => (!p ? "" : /^https?:\/\//.test(p) ? p : p.startsWith("/") ? AC.uploads + p : "");
const AC_NO_OVERVIEW = /aún no hemos añadido una sinopsis/i;
/** One API post as a Kino item; null for anything that is not a movie, show or anime. */
export function acItem(p) {
if (!p || !AC_TYPES[p.type] || !Number.isInteger(p._id) || !p.title || !/^[a-z0-9-]{1,200}$/.test(p.slug || "")) return null;
let title = decode(String(p.title)).trim();
let year = "";
const ym = /\s*\((\d{4})\)\s*$/.exec(title);
if (ym) { year = ym[1]; title = title.slice(0, ym.index).trim(); }
if (!year && /^\d{4}-/.test(p.release_date || "")) year = p.release_date.slice(0, 4);
const item = { id: `ac-${p.type}-${p._id}`, ref: `ac|${p.type}/${p._id}/${p.slug}`, title: title.slice(0, 200), kind: AC_TYPES[p.type] };
if (year) item.year = year;
const images = p.images || {};
const poster = acImage(images.poster);
if (poster) item.poster = poster;
const backdrop = acImage(images.backdrop);
if (backdrop) item.backdrop = backdrop;
const overview = text(p.overview || "");
if (overview && !AC_NO_OVERVIEW.test(overview)) item.overview = overview.slice(0, 2000);
const rating = parseFloat(p.rating);
if (rating > 0 && rating <= 10) item.rating = Math.round(rating * 10) / 10;
const original = decode(String(p.original_title || "")).trim();
if (original && original !== title) item.originalTitle = original.slice(0, 200);
const genres = (p.genres || []).map((g) => AC_GENRES[g]).filter(Boolean);
if (p.type === "animes" && !genres.includes("Anime")) genres.unshift("Anime");
if (genres.length) item.genres = [...new Set(genres)].slice(0, 5).map((g) => g.slice(0, 30));
const runtime = Math.round(parseFloat(p.runtime));
if (p.type === "movies" && runtime >= 1 && runtime <= 1000) item.runtimeMinutes = runtime;
return item;
}
function acItems(list) {
const seen = new Set();
return (list || []).map(acItem).filter((i) => i && !seen.has(i.id) && seen.add(i.id));
}
async function acSearch(q, { list = true } = {}) {
const data = await acGet(`/search?post_type=movies,tvshows,animes&query=${encodeURIComponent(q)}&posts_per_page=16`, { list });
return acItems(data && data.posts);
}
/** A row of the catalog: the newest updated titles of one type, 24 a page. */
async function acListing(type, n, { list = true } = {}) {
const data = await acGet(`/listing?page=${n}&post_type=${type}&posts_per_page=24`, { list });
const pg = (data && data.pagination) || {};
return { items: acItems(data && data.posts), more: Number.isInteger(pg.last_page) ? n < pg.last_page : !!pg.next_page_url };
}
async function acGenre(slug, n, { list = true, perPage = 24 } = {}) {
const data = await acGet(`/listing?tax=genres&term=${encodeURIComponent(slug)}&page=${n}&post_type=movies,tvshows,animes&posts_per_page=${perPage}`, { list });
return acItems(data && data.posts);
}
/**
* `ac|<type>/<id>/<slug>` (new) or `ac|<movie|tvshow|anime>/<tmdb>[/<s>/<e>]` (before 0.6.2, TMDB ids), or an episode
* `ac|ep/<post>/<tmdb show>/<s>/<e>`.
*/
export function parseAcRef(ref) {
const parts = parseServerRef(ref).base.slice(3).split("/");
const [a, b, c, d, e] = parts;
if (a === "ep" && /^\d+$/.test(b || "")) {
return { ep: true, post: b, tmdb: /^\d+$/.test(c || "") ? c : "", season: /^\d+$/.test(d || "") ? d : "", episode: /^\d+$/.test(e || "") ? e : "" };
}
if (AC_TYPES[a] && /^\d+$/.test(b || "")) return { type: a, post: b, slug: c || "" };
if (["movie", "tvshow", "anime"].includes(a) && /^\d+$/.test(b || "")) {
return { legacy: true, kind: a, tmdb: b, season: /^\d+$/.test(c || "") ? c : "", episode: /^\d+$/.test(d || "") ? d : "" };
}
return null;
}
const acLegacyError = () => kino.error("not_found", "referencia anterior a 0.6.2", {
userMessage: "AllCalidad cambió su catálogo. Busca el título de nuevo para ver sus episodios.",
});
/** `ac|tvshows/41641/dark-2017` -> its episodes, refs `ac|ep/<post>/<tmdb>/<s>/<e>`. */
async function acEpisodes(ref) {
const r = parseAcRef(ref);
if (r && r.legacy) throw acLegacyError();
if (!r || !r.type || r.type === "movies") throw kino.error("not_found", "referencia inválida");
const [list, info] = await Promise.all([
acGet(`/episodes?post_id=${r.post}`),
r.slug ? acGet(`/single?post_name=${encodeURIComponent(r.slug)}&post_type=${r.type}`).catch((e) => { kino.log(`episodes ac: no title info (${e.code || ""})`); return null; }) : null,
]);
const episodes = [];
let tmdb = "";
const seen = new Set();
for (const e of Array.isArray(list) ? list : []) {
if (!e || !Number.isInteger(e._id) || !Number.isInteger(e.season_number) || !Number.isInteger(e.episode_number) || e.episode_number < 1) continue;
const key = `${e.season_number}x${e.episode_number}`;
if (seen.has(key)) continue;
seen.add(key);
const show = /^\d+$/.test(String(e.show_id || "")) ? String(e.show_id) : "";
if (show && !tmdb) tmdb = show;
const ep = { season: e.season_number, number: e.episode_number, ref: `ac|ep/${e._id}/${show || 0}/${e.season_number}/${e.episode_number}` };
const t = decode(String(e.title || "")).trim();
if (t && !/temporada\s*\d+\s*episodio\s*\d+\s*$/i.test(t)) ep.title = t.slice(0, 200);
const ov = text(e.overview || "");
if (ov && !AC_NO_OVERVIEW.test(ov)) ep.overview = ov.slice(0, 2000);
if (/^\/\w+\.(jpg|png|webp)$/i.test(e.still_path || "")) ep.still = AC.tmdbImg + "w300" + e.still_path;
const rt = Math.round(parseFloat(e.runtime));
if (rt >= 1 && rt <= 1000) ep.runtimeMinutes = rt;
episodes.push(ep);
}
if (!episodes.length) throw kino.error("not_found", "la serie no tiene episodios en AllCalidad", { userMessage: "AllCalidad todavía no tiene episodios de esta serie." });
episodes.sort((a, b) => a.season - b.season || a.number - b.number);
const series = {};
const base = acItem(info) || {};
for (const k of ["title", "poster", "backdrop", "overview", "year", "genres"]) if (base[k]) series[k] = base[k];
if (tmdb) series.ids = { tmdb: parseInt(tmdb, 10) };
// An episode ref carries only the show's TMDB id: its name is kept here, for resolve's search on the other sites.
const name = base.title || acShowName(list);
if (tmdb && name) storageSet(showKey(tmdb), { title: name, ...(base.originalTitle ? { originalTitle: base.originalTitle } : {}), ...(base.year ? { year: base.year } : {}) }, IMDB_MEMORY_MS);
kino.log(`episodes ${ref}: ${episodes.length}${tmdb ? ", TMDB id known" : ""}`);
return { series, episodes };
}
/** "Dark: Temporada 1 Episodio 2" -> "Dark": the show's name as its episodes' titles carry it (when /single fails). */
export function acShowName(list) {
for (const e of Array.isArray(list) ? list : []) {
const m = /^(.+?):\s*Temporada\s*\d+\s*Episodio\s*\d+\s*$/i.exec(decode(String((e && e.title) || "")).trim());
if (m) return m[1].trim().slice(0, 200);
}
return "";
}
/** What allcalidad's episode refs need to be found elsewhere: the show's name, per TMDB id. */
const showKey = (tmdb) => "show:ac:" + tmdb;
/** The videoapp page for a TMDB title: movie, or a show's episode. */
export function videoappUrl(kind, tmdb, season, episode) {
if (!/^\d+$/.test(String(tmdb || "")) || String(tmdb) === "0") return "";
if (kind === "movie") return `${VIDEOAPP}/e/movie/${tmdb}`;
if (!/^\d+$/.test(String(season || "")) || !/^\d+$/.test(String(episode || ""))) return "";
return `${VIDEOAPP}/e/tv/${tmdb}/${season}/${episode}`;
}
/** A player answer's embeds as resolve's server list `{ lang, server, url, referer }` (server named after its host). */
export function acServers(answer) {
return ((answer && answer.embeds) || [])
.filter((e) => e && /^https:\/\//.test(e.url || ""))
.map((e) => ({ lang: e.lang || "", server: hostLabel(e.url), url: e.url, referer: AC.base + "/" }));
}
/** allcalidad's servers for a ref: the player API's embeds; for an old TMDB ref, its videoapp page only. */
async function acServerList(ref) {
const r = parseAcRef(ref);
if (!r) throw kino.error("not_found", "referencia inválida");
if (r.legacy) {
const url = videoappUrl(r.kind === "movie" ? "movie" : "tv", r.tmdb, r.season, r.episode);
if (!url) throw acLegacyError();
return [{ lang: "LAT", server: "videoapp", url, referer: AC.base + "/" }];
}
const out = acServers(await acGet(`/player?post_id=${r.post}&_any=1`));
const app = r.ep ? videoappUrl("tv", r.tmdb, r.season, r.episode) : "";
if (app && !out.some((f) => startHost(f.url) === startHost(app))) out.push({ lang: "LAT", server: "videoapp", url: app, referer: AC.base + "/" });
return out;
}
// ---------- sololatino ("browser": "pages": kino.browser.page where a plain read hits Cloudflare) ----------
//
// sololatino.net, measured 2026-10-04: its catalogs, series, movie and episode pages answer a plain request; its
// /buscar page answers Cloudflare's "Just a moment…", but the site's own search box reads a JSON suggest API that
// answers fine. Each episode or movie page lists its servers as encrypted "player tokens" per language
// (data-lang-group mx / es / sub); the page's own script turns one into the player URL with Laravel Sanctum
// (GET /sanctum/csrf-cookie, then POST /api/player-url { t } with the XSRF cookie's value as X-XSRF-TOKEN). One of
// those players is an embed69 page (embed69.org/f/<imdb>-<s>x<ee>), the same as serieskao's, so the same decryption
// follows. When a plain read does get the challenge page, the hidden browser reads the page instead
// (kino.browser.page), never for Home rows.
const SL = { id: "sl", name: "SoloLatino", base: "https://sololatino.net" };
/** A desktop browser's headers: with them the catalog and title pages answer a plain request (a phone's UA got 403). */
const SL_HEADERS = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36",
Accept: "text/html,application/xhtml+xml", "Accept-Language": "es-CO,es;q=0.9",
};
const SL_CATALOG = { serie: "/series?page=", anime: "/animes?page=", pelicula: "/peliculas?page=" };
/** A page read through the hidden browser is kept this long: a person paging back and forth never re-opens it. */
const SL_PAGE_CACHE_MS = 5 * 60 * 1000;
/**
* Pages read through the hidden browser, kept in the sandbox's memory (it lives while the person keeps using the
* plugin): a real catalog page is ~230 KB, too big for kino.storage's 256 KB in all. At most [SL_PAGE_MEMO_MAX].
*/
const slPageMemo = new Map();
const SL_PAGE_MEMO_MAX = 8;
function slMemoGet(path) {
const hit = slPageMemo.get(path);
if (hit && hit.until > Date.now()) return hit.html;
slPageMemo.delete(path);
const stored = storageGet("slpage:" + path);
return stored && stored.html ? stored.html : null;
}
function slMemoSet(path, html) {
slPageMemo.delete(path);
slPageMemo.set(path, { html, until: Date.now() + SL_PAGE_CACHE_MS });
while (slPageMemo.size > SL_PAGE_MEMO_MAX) slPageMemo.delete(slPageMemo.keys().next().value);
// Small pages also survive a sandbox restart.
if (html.length <= 40000) storageSet("slpage:" + path, { html }, SL_PAGE_CACHE_MS);
}
/** Test hook: forget the pages read in this sandbox. */
export function forgetPages() {
slPageMemo.clear();
}
/** After the hidden browser was blocked or timed out, sololatino is left alone this long (no page after page). */
const SL_DOWN_MS = 15 * 60 * 1000;
const SL_PAGE_TIMEOUT_MS = 10000;
const slDownKey = "sitedown:sl";
export function slIsDown(nowMs = Date.now()) {
const until = (storageGet(slDownKey) || {}).until || 0;
return until > nowMs;
}
function slUnavailable(why) {
return kino.error("unavailable", `${SL.name}: ${why}`, { userMessage: `${SL.name} no está dejando entrar en este momento. Las otras fuentes siguen funcionando.` });
}
/**
* One sololatino page: a plain read first; on Cloudflare's challenge, the hidden browser ("browser": "pages") when
* [allowPage] — never for Home rows, which Kino also asks for on its own. A blocked or timed-out page read marks the
* site down for [SL_DOWN_MS]; while down, nothing is read at all.
*/
export async function slRead(path, { allowPage = true, waitFor, list = false } = {}) {
await null;
if (slIsDown()) throw slUnavailable("en pausa tras un bloqueo");
const url = SL.base + path;
const cached = slMemoGet(path);
if (cached) return cached;
const r = await siteFetch(SL.id, url, { headers: SL_HEADERS }, { list });
const html = r.text();
if (r.ok && !looksLikeChallenge(html)) return html;
if (r.status === 404) throw kino.error("not_found", `${SL.name} respondió 404`, { userMessage: `${SL.name} ya no tiene esta página.` });
if (!looksLikeChallenge(html) && r.status !== 403) {
report("maraton:site", "http", "site=sl", `status=${r.status}`);
throw kino.error("unavailable", `${SL.name} respondió ${r.status}`, { userMessage: `${SL.name} no está respondiendo bien (${r.status}).` });
}
report("maraton:site", "cloudflare", "site=sl");
if (!allowPage || browserMissing() || !kino.browser || typeof kino.browser.page !== "function") {
if (list) siteFailed(SL.id, "cloudflare");
throw slUnavailable("verificación de Cloudflare");
}
const t1 = Date.now();
try {
const got = await kino.browser.page(url, { timeoutMs: SL_PAGE_TIMEOUT_MS, ...(waitFor ? { waitFor } : {}) });
kino.log(`page ${safe(url)} -> ${got.status} in ${Date.now() - t1} ms${got.truncated ? " (truncated)" : ""}`);
slMemoSet(path, got.html);
return got.html;
} catch (e) {
kino.log(`page ${safe(url)}: ${e.code || ""} ${e.message} after ${Date.now() - t1} ms`);
if (e.code === "browser_unavailable") noteNoBrowser();
if (e.code === "blocked" || e.code === "timeout") {
storageSet(slDownKey, { until: Date.now() + SL_DOWN_MS }, SL_DOWN_MS);
report("maraton:page", e.code, "site=sl");
}
throw slUnavailable(e.code || "page");
}
}
/** sololatino's `<div class="card">`: link, poster (alt = title), kind badge, rating, year. */
export function slCards(html) {
const out = [];
const seen = new Set();
const rx = /<div class="card">\s*<a href="([^"]+)">([\s\S]*?)<\/div>\s*<\/div>/gi;
let m;
while ((m = rx.exec(html))) {
const path = String(m[1]).replace(SL.base, "");
if (!/^\/(serie|pelicula)\/[^/]+$/.test(path) || seen.has(path)) continue;
seen.add(path);
const block = m[2];
const img = /<img[\s\S]*?>/i.exec(block);
const title = text((/card__title">([\s\S]*?)<\/p>/i.exec(block) || [])[1]) || (img ? attr(img[0], "alt") : "");
if (!title) continue;
const kind = path.startsWith("/pelicula/") ? "movie" : "series";
const item = { id: idOf("sl", path), ref: `sl|${path}`, title, kind };
const poster = img ? attr(img[0], "src") : "";
if (/^https?:\/\//.test(poster)) item.poster = poster;
const year = text((/card__year">([\s\S]*?)</i.exec(block) || [])[1]);
if (/^\d{4}$/.test(year)) item.year = year;
const rating = parseFloat((/card__rating">\s*★?\s*([\d.]+)/i.exec(block) || [])[1]);
if (rating > 0 && rating <= 10) item.rating = Math.round(rating * 10) / 10;
if (/badge-anime/.test(block)) item.genres = ["Anime"];
out.push(item);
}
return out;
}
/** The suggest API's answer as items (series, anime and movies; its own type names them). */
export function slSuggestItems(list) {
const out = [];
for (const it of Array.isArray(list) ? list : []) {
const path = String((it && it.url) || "").replace(SL.base, "");
if (!/^\/(serie|pelicula)\/[^/]+$/.test(path) || !it.title) continue;
const item = { id: idOf("sl", path), ref: `sl|${path}`, title: String(it.title).slice(0, 200), kind: path.startsWith("/pelicula/") ? "movie" : "series" };
if (/^https?:\/\//.test(it.poster || "")) item.poster = it.poster;
if (it.year) item.year = String(it.year);
if (it.type === "anime") item.genres = ["Anime"];
out.push(item);
}
return out;
}
/** Search: the JSON suggest API (plain), else the /buscar page through the hidden browser. Cached 10 min per text. */
async function slSearch(q) {
await null;
const key = "slsearch:" + q.toLowerCase().slice(0, 100);
const cached = storageGet(key);
if (cached && Array.isArray(cached.items)) return cached.items;
if (slIsDown()) throw slUnavailable("en pausa tras un bloqueo");
if (siteResting(SL.id)) throw restingError(SL.id);
let items = null;
try {
const r = await siteFetch(SL.id, `${SL.base}/api/search/suggest?q=${encodeURIComponent(q)}`, {
headers: { ...SL_HEADERS, Accept: "application/json", Referer: SL.base + "/" },
}, { list: true });
if (r.ok) items = slSuggestItems(r.json());
else kino.log(`search sl suggest -> ${r.status}`);
} catch (e) {
kino.log(`search sl suggest: ${e.code || ""} ${e.message}`);
// The site itself did not answer: a page read would not do better.
if (e.code === "unavailable") throw e;
}
// Short texts never open the hidden browser: the person is still typing.
if (!items && q.length >= 3) items = slCards(await slRead(`/buscar?q=${encodeURIComponent(q)}`, { waitFor: 'class="card"', list: true }));
if (!items) items = [];
storageSet(key, { items }, 10 * 60 * 1000);
return items;
}
/**
* hacktorrent-mirror's normalize.py: sololatino numbers some shows ABSOLUTELY (Naruto's "temporada-2" starts at
* episode 53). A show is absolute when any season after the first starts above episode 2.
*/
export function numberingStyle(list) {
const bySeason = new Map();
for (const e of list) {
const s = bySeason.get(e.season) || [];
s.push(e.number);
bySeason.set(e.season, s);
}
const seasons = [...bySeason.keys()].sort((a, b) => a - b);
return seasons.slice(1).some((s) => Math.min(...bySeason.get(s)) > 2) ? "absolute" : "relative";
}
/** An absolute episode number placed in TMDB's seasons ([{ season, count }], specials skipped); null past the end. */
export function remapAbsolute(n, seasons) {
let left = n;
for (const s of [...seasons].sort((a, b) => a.season - b.season)) {
if (s.season === 0) continue;
if (left <= s.count) return [s.season, left];
left -= s.count;
}
return null;
}
const titleKey = (t) => String(t || "").toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/\(\d{4}\)/g, "").replace(/[^a-z0-9]+/g, " ").trim();
/**
* TMDB's season sizes for a show, counted from allcalidad's episode list of the show with the same title (its episodes
* are TMDB's). Used only when it covers [needed] episodes: a partial list would squeeze every later episode out.
*/
async function tmdbSeasonsFor(title, needed) {
const want = titleKey(title);
const found = (await acSearch(title, { list: false })).find((i) => i.kind === "series" && titleKey(i.title) === want);
if (!found) return null;
const r = parseAcRef(found.ref);
const list = await acGet(`/episodes?post_id=${r.post}`);
const counts = new Map();
for (const e of Array.isArray(list) ? list : []) {
if (e && Number.isInteger(e.season_number) && e.season_number >= 1 && Number.isInteger(e.episode_number)) {
counts.set(e.season_number, Math.max(counts.get(e.season_number) || 0, e.episode_number));
}
}
const seasons = [...counts.entries()].map(([season, count]) => ({ season, count })).sort((a, b) => a.season - b.season);
const total = seasons.reduce((n, s) => n + s.count, 0);
if (!seasons.length || total < needed || seasons.some((s, i) => s.season !== i + 1)) return null;
return seasons;
}
/** A sololatino series page's episodes: anchors to /temporada-N/episodio-M with their still and title. */
export function slEpisodes(html, path) {
const out = [];
const seen = new Set();
const rx = /<a href="(https:\/\/sololatino\.net\/serie\/[^"]+\/temporada-(\d+)\/episodio-(\d+))"([\s\S]*?)<\/a>/gi;
let m;
while ((m = rx.exec(html))) {
const epPath = m[1].replace(SL.base, "");
if (seen.has(epPath)) continue;
seen.add(epPath);
const ep = { season: parseInt(m[2], 10), number: parseInt(m[3], 10), ref: `sl|${epPath}` };
if (!ep.season || !ep.number) continue;
const img = /<img[^>]+>/i.exec(m[4]);
const still = img ? attr(img[0], "src") : "";
if (/^https?:\/\//.test(still)) ep.still = still;
const t = text((/<p class="text-sm[^"]*">([\s\S]*?)<\/p>/i.exec(m[4]) || [])[1]);
if (t && !/^E\d+$/.test(t)) ep.title = t.slice(0, 200);
out.push(ep);
}
out.sort((a, b) => a.season - b.season || a.number - b.number);
return out;
}
async function slEpisodesOf(ref) {
const path = ref.slice(3);
const html = await slRead(path, { waitFor: "episodio-" });
let list = slEpisodes(html, path);
if (!list.length) throw kino.error("not_found", "la serie no tiene episodios en SoloLatino");
const series = {};
const ld = /"@type":"TVSeries"[\s\S]*?"name":"((?:[^"\\]|\\.)*)"/.exec(html);
if (ld) { try { series.title = JSON.parse(`"${ld[1]}"`); } catch (_) { /* the h1 below */ } }
if (!series.title) series.title = text((/<h1[^>]*>([\s\S]*?)<\/h1>/i.exec(html) || [])[1]);
const og = /<meta property="og:image" content="([^"]+)"/i.exec(html);
if (og && /^https?:\/\//.test(og[1])) series.poster = og[1];
const desc = /<meta name="description" content="([^"]*)"/i.exec(html);
if (desc) series.overview = decode(desc[1]).slice(0, 2000);
const imdb = (/imdb\.com\/title\/(tt\d{5,10})/.exec(html) || [])[1];
if (imdb) {
series.ids = { imdb };
storageSet(imdbKey(imdb), { ref }, IMDB_MEMORY_MS);
}
if (numberingStyle(list) === "absolute" && series.title && activeSites().includes(AC.id) && !siteResting(AC.id)) {
try {
const needed = Math.max(...list.map((e) => e.number));
const seasons = await within(tmdbSeasonsFor(series.title, needed), 6000, null);
if (seasons) {
const remapped = [];
for (const e of list) {
const at = remapAbsolute(e.number, seasons);
if (at) remapped.push({ ...e, season: at[0], number: at[1] });
}
kino.log(`episodes ${ref}: absolute numbering remapped onto ${seasons.length} seasons (${list.length} -> ${remapped.length})`);
list = remapped;
} else kino.log(`episodes ${ref}: absolute numbering, but no complete season list: the site's numbers stay`);
} catch (e) {
kino.log(`episodes ${ref}: no season list for the remap (${e.code || ""} ${e.message}); the site's numbers stay`);
}
}
kino.log(`episodes ${ref}: ${list.length}`);
return { series, episodes: list };
}
/** The episode or movie page's player tokens, per language group: `[{ lang, token, label }]`. */
export function slTokens(html) {
const out = [];
const groups = /<div data-lang-group="([a-z]+)"[^>]*>([\s\S]*?)<\/div>\s*<\/div>/gi;
let g;
while ((g = groups.exec(html))) {
const lang = g[1] === "mx" ? "LAT" : g[1] === "es" ? "ESP" : "SUB";
const btn = /<button[^>]*data-player-token="([^"]+)"[^>]*>([\s\S]*?)<\/button>/gi;
let b;
while ((b = btn.exec(g[2]))) out.push({ lang, token: b[1], label: text(b[2]).replace(/[^\p{L}\p{N} ]/gu, "").trim().toLowerCase() });
}
return out;
}
/** One player token to its player URL, the way the page's own script does (POST with the XSRF cookie's value). */
async function slPlayerUrl(token, referer, xsrf) {
const r = await kino.fetch(`${SL.base}/api/player-url`, {
method: "POST",
headers: { ...SL_HEADERS, Accept: "application/json", "Content-Type": "application/json", Referer: referer, Origin: SL.base, ...(xsrf ? { "X-XSRF-TOKEN": xsrf } : {}) },
body: { json: { t: token } }, timeoutMs: LIMITS.fetchMs,
});
if (!r.ok) {
kino.log(`sl player-url -> ${r.status}`);
return null;
}
const url = (r.json() || {}).url;
return /^https:\/\//.test(url || "") ? url : null;
}
/**
* sololatino's servers for an episode or movie: the player tokens resolved together (one XSRF cookie for all), embed69
* players decrypted. `pages` is the episode page itself: the last resort when no embed69 player listed anything.
*/
async function slServers(ref) {
const path = parseServerRef(ref).base.slice(3);
const pageUrl = SL.base + path;
const html = await slRead(path, { waitFor: "data-player-token" });
const tokens = slTokens(html).slice(0, 6);
kino.log(`resolve ${ref}: ${tokens.length} player token(s) [${tokens.map((t) => `${t.lang}/${t.label}`).join(", ")}]`);
if (tokens.length) {
await kino.fetch(`${SL.base}/sanctum/csrf-cookie`, { headers: { ...SL_HEADERS, Referer: pageUrl }, timeoutMs: LIMITS.fetchMs })
.catch((e) => kino.log(`sl csrf-cookie: ${e.code || ""} ${e.message}`));
}
const xsrf = decodeURIComponent((kino.cookies && kino.cookies.get(SL.base + "/", "XSRF-TOKEN")) || "");
const urls = await Promise.all(tokens.map((t) => slPlayerUrl(t.token, pageUrl, xsrf).catch((e) => { kino.log(`sl player-url: ${e.code || ""} ${e.message}`); return null; })));
const players = [...new Set(urls.filter(Boolean))];
const lists = await Promise.all(players.map(async (url) => {
if (!/^https:\/\/embed69\.org\//.test(url)) {
kino.log(`sl player ${safe(url)}: not an embed69 page, skipped`);
return [];
}
return embed69From(url, pageUrl, "sl");
}));
const out = [];
for (const f of [].concat(...lists)) if (!out.some((o) => o.url === f.url)) out.push(f);
return { servers: out, pages: out.length ? [] : [pageUrl], html };
}
// ---------- telemetry ----------
//
// With "telemetry": true, a failed call's own kino.log lines already reach Kino's error board. kino.log.report is for
// what WORKED but degraded, by area: maraton:site (a site down, a 5xx, Cloudflare), maraton:page (a page read blocked),
// maraton:embed69 (its page changed or could not be read), maraton:extract (an embed's page no longer carries its
// playlist), maraton:capture (a hidden page showed no video, per server), maraton:browser (a device without a usable
// WebView), maraton:retry (Kino came back because the stream was refused), maraton:fallback (the ref's own site had no
// video and another site played the same title) and maraton:resolve (no copy at all, why, and how the fallback ended).
// Only codes and counts go in a report — our own site and server names, a status number, milliseconds — never a URL, a
// title or anything the person typed. Kino scrubs lines too, and allows one report per area an hour and 3 per plugin
// until it restarts, so the rare and telling ones are the ones reported.
/** `area` is namespaced ("maraton:site"); the rest are short codes. No-op where kino.log.report does not exist. */
export function report(area, ...codes) {
try {
if (kino.log && typeof kino.log.report === "function") kino.log.report(area, ...codes);
else kino.log(area, ...codes);
} catch (_) { /* a report never breaks a call */ }
}
const siteCode = (site) => Object.keys(SITES).find((k) => SITES[k] === site) || "?";
// ---------- small helpers ----------
function abs(site, href) {
if (/^https?:\/\//i.test(href)) return href;
return site.base + (href.startsWith("/") ? href : "/" + href);
}
function pathOf(site, url) {
return url.startsWith(site.base) ? url.slice(site.base.length) : url;
}
function text(html) {
return decode(String(html || "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim());
}
function decode(s) {
return String(s).replace(/&/g, "&").replace(/"/g, '"').replace(/�?39;/g, "'").replace(/</g, "<").replace(/>/g, ">")
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)));
}
function attr(tag, name) {
const m = new RegExp(`${name}="([^"]*)"`, "i").exec(tag);
return m ? decode(m[1]) : "";
}
function idOf(siteId, path) {
return (siteId + "-" + path.replace(/^\/+/, "").replace(/[^A-Za-z0-9._~-]+/g, "-")).slice(0, 128);
}
/** Host and path only: never a query string (tokens live there). */
function safe(url) {
const m = /^https?:\/\/([^/?#]+)([^?#]*)/i.exec(url || "");
return m ? m[1] + m[2].slice(0, 100) : "?";
}
function startHost(url) {
try { return new URL(url).host; } catch (_) { return ""; }
}
function originOf(url) {
const m = /^(https?:\/\/[^/?#]+)/i.exec(url || "");
return m ? m[1] : "";
}
/** `https://www.vimeos.net/…` -> "vimeos": the name a server goes by when the site does not give one. */
function hostLabel(url) {
return startHost(url).toLowerCase().replace(/^www\./, "").split(".")[0] || "";
}
/**
* A Cloudflare (or similar) interstitial instead of the page. Not the passive "challenge-platform" beacon some zones
* add to every real page (hacktorrent-mirror measured that on sololatino): the interstitial's own title or its
* `_cf_chl_opt` bootstrap.
*/
export function looksLikeChallenge(html) {
const h = String(html || "");
return /<title>\s*just a moment/i.test(h) || /_cf_chl_opt/.test(h) || /<title>\s*attention required/i.test(h);
}
async function page(site, path, { list = false } = {}) {
const id = siteCode(site);
const r = await siteFetch(id, abs(site, path), { headers: { "User-Agent": UA, Accept: "text/html" } }, { list });
if (!r.ok) {
if (r.status !== 404) report("maraton:site", "http", `site=${id}`, `status=${r.status}`);
throw kino.error(r.status === 404 ? "not_found" : r.status === 429 ? "rate_limited" : "unavailable", `${site.name} respondió ${r.status}`,
{ userMessage: r.status === 404 ? `${site.name} ya no tiene esta página.` : `${site.name} no está respondiendo bien (${r.status}).` });
}
const html = r.text();
// Sites behind Cloudflare at times answer an interstitial: "unavailable", never parsed as content.
if (looksLikeChallenge(html)) {
siteFailed(id, "cloudflare");
report("maraton:site", "cloudflare", `site=${id}`);
throw kino.error("unavailable", `${site.name} pide verificación de Cloudflare`, { userMessage: `${site.name} está pidiendo una verificación que no se puede pasar desde aquí.` });
}
return html;
}
// ---------- cards (search results, catalogs) ----------
/** serieskao's `<article class="card">`: link, poster, title, year, type badge. */
function skCards(siteId, html) {
const site = SITES[siteId];
const out = [];
const seen = new Set();
const rx = /<article class="card">([\s\S]*?)<\/article>/gi;
let m;
while ((m = rx.exec(html))) {
const block = m[1];
const link = /<a href="([^"]+)" class="card__link"/i.exec(block);
if (!link) continue;
const path = pathOf(site, decode(link[1]));
if (!/^\/(serie|anime|pelicula)\//.test(path) || seen.has(path)) continue;
seen.add(path);
// Search and catalogs say <h2>, the home page's "Recién agregado" says <h3>.
const title = text((/<h[23] class="card__title">([\s\S]*?)<\/h[23]>/i.exec(block) || [])[1]);
if (!title) continue;
const img = /<img[^>]+>/i.exec(block);
const year = text((/card__badge--year">([\s\S]*?)</i.exec(block) || [])[1]);
const kind = path.startsWith("/pelicula/") ? "movie" : "series";
const item = { id: idOf(siteId, path), ref: `${siteId}|${path}`, title, kind };
const poster = img ? attr(img[0], "src") : "";
if (/^https?:\/\//.test(poster)) item.poster = poster;
if (/^\d{4}$/.test(year)) item.year = year;
if (path.startsWith("/anime/")) item.genres = ["Anime"];
// "★ 7.7": the site's TMDB rating (0 to 10). A missing or out-of-range one is left out, never guessed.
const rating = parseFloat(text((/card__rating">([\s\S]*?)<\/span>/i.exec(block) || [])[1]));
if (rating > 0 && rating <= 10) item.rating = Math.round(rating * 10) / 10;
out.push(item);
}
return out;
}
function cards(siteId, html) {
return skCards(siteId, html);
}
// ---------- search ----------
/**
* `scopedSearch`: what a "Ver más" page's own search field asks (`query.within` = that page's browse ref). A catalog
* row is answered with the site's search, kept to that row's site and kind (a "Series" row never shows a movie); a
* genre page is answered with `null`, which tells Kino to filter the titles it already loaded.
*/
export function scopeOf(within) {
const [head, siteId, kind] = String(within || "").split("|");
if (head !== "row" || !ALL_SITES.includes(siteId)) return null;
if (siteId === "sk" && SITES.sk.catalog[kind]) return { siteId, keep: (i) => i.ref.startsWith(`sk|/${kind}/`) };
if (siteId === AC.id && AC_TYPES[kind]) return { siteId, keep: (i) => i.ref.startsWith(`ac|${kind}/`) };
return null;
}
async function scopedSearch(query, q) {
const scope = scopeOf(query.within);
if (!scope || !activeSites().includes(scope.siteId)) return null;
const found = scope.siteId === AC.id ? await acSearch(q) : cards("sk", await page(SITES.sk, SITES.sk.search(q), { list: true }));
const kept = found.filter(scope.keep);
kino.log(`search within ${scope.siteId} row: ${found.length} found, ${kept.length} in the row`);
return kino.rank.sortBySimilarity(kept, q, (it) => it.title).slice(0, 100);
}
/** One site's search, trying [tries] in order until something comes back. Throws when the site failed. */
async function searchSite(siteId, tries) {
if (siteId === SL.id) return slSearch(tries[0]); // one try only: each may cost a page read
for (const t of tries) {
const t0 = Date.now();
const found = siteId === AC.id ? await acSearch(t) : cards(siteId, await page(SITES[siteId], SITES[siteId].search(t), { list: true }));
kino.log(`search ${siteId} "${t.slice(0, 60)}": ${found.length} results in ${Date.now() - t0} ms`);
if (found.length) return found;
}
return [];
}
export async function search(query) {
const q = String((query && query.q) || "").trim();
if (!q) return [];
if (query.within) return scopedSearch(query, q);
// The guide's advice: the title Kino typed, its head (kino.rank.shortQuery), then TMDB's original title and the
// other titles Kino knows, tried in that order until a site answers something. At most four tries per site.
const tries = [q, kino.rank.shortQuery(q), query.originalTitle, ...(query.altTitles || [])]
.map((t) => String(t || "").trim()).filter((v, i, a) => v && a.indexOf(v) === i).slice(0, 4);
const sites = activeSites();
let failed = 0;
const late = [];
const lists = await Promise.all(sites.map(async (siteId) => {
try {
const found = await within(searchSite(siteId, tries), LIMITS.searchMs, null);
if (found === null) {
late.push(siteId);
failed++;
return [];
}
return found;
} catch (e) {
kino.log(`search ${siteId}: ${e.code || ""} ${e.message}`);
failed++;
return [];
}
}));
if (late.length) kino.log(`search: ${late.join(", ")} did not answer in ${LIMITS.searchMs} ms, left out`);
const all = [].concat(...lists);
// Every site failed: say so, instead of an empty "no results" that reads as "it does not exist".
if (!all.length && failed === sites.length) {
throw kino.error("unavailable", "ningún sitio respondió", { userMessage: "Los sitios de PelisYSeries no están respondiendo. Vuelve a intentar en un rato." });
}
const ranked = kino.rank.filterRelevant(kino.rank.sortBySimilarity(all, q, (it) => it.title), q, (it) => it.title).slice(0, 60);
kino.log(`search: ${all.length} found, ${ranked.length} kept${ranked.length ? `, best ${ranked[0].ref}` : ""}`);
return ranked;
}
// ---------- rows (Home, the plugin's own page) ----------
/**
* serieskao's home "Últimos Episodios": one card per new episode. Each becomes its SERIES (Kino opens the show, the
* person picks the episode), once per show, with the newest episode as a badge ("T1 E3").
*/
export function latestEpisodes(siteId, html) {
const site = SITES[siteId];
const out = [];
const seen = new Set();
const rx = /<article class="episode-card">([\s\S]*?)<\/article>/gi;
let m;
while ((m = rx.exec(html))) {
const block = m[1];
const href = (/<a href="([^"]+)"/i.exec(block) || [])[1];
if (!href) continue;
const path = pathOf(site, decode(href)).replace(/\/temporada\/\d+\/capitulo\/\d+\/?$/, "");
if (!/^\/(serie|anime)\/[^/]+$/.test(path) || seen.has(path)) continue;
seen.add(path);
const title = text((/episode-card__title">([\s\S]*?)<\/h3>/i.exec(block) || [])[1]);
if (!title) continue;
const item = { id: idOf(siteId, path), ref: `${siteId}|${path}`, title, kind: "series" };
const img = /<img[^>]+>/i.exec(block);
const poster = img ? attr(img[0], "src") : "";
if (/^https?:\/\//.test(poster)) item.poster = poster;
const badge = text((/episode-card__badge">([\s\S]*?)</i.exec(block) || [])[1]);
item.badges = badge ? ["Nuevo episodio", badge.slice(0, 20)] : ["Nuevo episodio"];
if (path.startsWith("/anime/")) item.genres = ["Anime"];
out.push(item);
}
return out;
}
/** The home page's "Recién Agregado" section only (its cards, movies included). */
function recentlyAdded(siteId, html) {
const i = html.search(/section__title">\s*Recién Agregado/i);
return i < 0 ? [] : cards(siteId, html.slice(i));
}
const ROW_ITEM_KEYS = ["id", "ref", "title", "kind", "year", "poster", "backdrop", "badges"];
/**
* What a saved row keeps of each item, and how many: kino.storage holds 256 KB for everything (measured: 18 full rows
* of 24 took 134 KB; 12 Home rows of 16 plus 6 genre rows of 10, slim, 67 KB).
*/
const slimItem = (i) => Object.fromEntries(ROW_ITEM_KEYS.filter((k) => i[k] !== undefined).map((k) => [k, i[k]]));
const rowKey = (id) => "row:" + id;
/**
* One row's items with stale-while-revalidate: a row read in the last [LIMITS.rowFreshMs] is shown as it is; else the
* site is asked, and whatever it does not answer by [deadlineAt] — late, down, resting — is replaced by its last good
* items (kept [LIMITS.rowKeepMs]). The late read still saves its answer if it lands while the sandbox lives. Never
* throws: no items at all is [].
*/
async function freshRow(id, load, deadlineAt, keep = 16) {
const cached = storageGet(rowKey(id));
if (cached && Array.isArray(cached.items) && Date.now() - (cached.at || 0) < LIMITS.rowFreshMs) return cached.items;
const t0 = Date.now();
const loading = Promise.resolve().then(load).then((items) => {
if (items && items.length) storageSet(rowKey(id), { items: items.slice(0, keep).map(slimItem), at: Date.now() }, LIMITS.rowKeepMs);
return items || [];
});
let items = null;
try {
items = await within(loading, deadlineAt - Date.now(), null);
if (items === null) kino.log(`row ${id}: no answer in ${Date.now() - t0} ms${cached ? ", last good rows shown" : ""}`);
} catch (e) {
kino.log(`row ${id}: ${e.code || ""} ${e.message}${cached ? ", last good rows shown" : ""}`);
}
if (items && items.length) return items;
return cached && Array.isArray(cached.items) ? cached.items : [];
}
/** What each catalog row reads (the first page of the site's own catalog). */