stremiok 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -107,7 +107,9 @@
107
107
  for (var i = 0; i < SUB_FONTS.length; i++) { if (SUB_FONTS[i][0] === id) { return SUB_FONTS[i][2]; } }
108
108
  return '';
109
109
  }
110
- function setSubCfg(cfg) { U.store.set(SUBCFG_KEY, cfg); }
110
+ var _subCfgVer = 0; // bumped on every change: a styled line knows it's up to date
111
+ function setSubCfg(cfg) { U.store.set(SUBCFG_KEY, cfg); _subCfgVer++; }
112
+ function subCfgVersion() { return _subCfgVer; }
111
113
 
112
114
  // Preferred audio language ('default' = keep the stream's first audio track) and a
113
115
  // second choice used when the first isn't on the stream ('none' = no second choice).
@@ -236,13 +238,15 @@
236
238
  ['zoom', 'Zoom (fill the screen, trims edges)', 'PLAYER_DISPLAY_MODE_CROPPED_FULL', 'cover'],
237
239
  ['stretch', 'Stretch', 'PLAYER_DISPLAY_MODE_FULL_SCREEN', 'fill']
238
240
  ];
239
- // + seek: how far LEFT / RIGHT jump (s); buffer: 'normal' (the TV's own) | 'more' | 'most'
241
+ // + seek: how far LEFT / RIGHT jump (s); buffer: 'normal' (the TV's own) | 'more' | 'most';
242
+ // reconnect: a live channel that drops, 'off' | 'few' | 'always' (see player-core's liveDropped)
240
243
  // (Settings › Playback › Seeking & buffering)
241
244
  var SEEK_STEPS = [5, 10, 15, 30];
242
245
  function playerCfg() {
243
246
  var d = U.store.get(PLAYERCFG_KEY, {}) || {};
244
247
  return { aspect: d.aspect || 'fit', seek: SEEK_STEPS.indexOf(+d.seek) !== -1 ? +d.seek : 10,
245
248
  buffer: d.buffer === 'more' || d.buffer === 'most' ? d.buffer : 'normal',
249
+ reconnect: d.reconnect === 'off' || d.reconnect === 'always' ? d.reconnect : 'few',
246
250
  phone: d.phone === 'infuse' || d.phone === 'link' ? d.phone : 'vlc' }; // (Continue on phone: what the QR opens)
247
251
  }
248
252
  function setPlayerCfg(cfg) { // (what's given changes; the rest stays)
@@ -263,5 +267,5 @@
263
267
  P.recallAudio = recallAudio; P.playerCfg = playerCfg; P.setPlayerCfg = setPlayerCfg;
264
268
  P.aspectDef = aspectDef; P.SUB_FONTS = SUB_FONTS; P.SUB_COLORS = SUB_COLORS; P.SUB_BGS = SUB_BGS;
265
269
  P.SUB_EDGES = SUB_EDGES; P.SUB_POSITIONS = SUB_POSITIONS; P.ASPECTS = ASPECTS;
266
- P.isPrivate = isPrivate; P.setPrivate = setPrivate;
270
+ P.isPrivate = isPrivate; P.setPrivate = setPrivate; P.privateNow = privateNow; P.subCfgVersion = subCfgVersion;
267
271
  })(PlayerParts);
package/js/player-subs.js CHANGED
@@ -330,8 +330,12 @@
330
330
  el.className = '';
331
331
  var span = el.firstChild;
332
332
  if (!span) { span = document.createElement('span'); el.appendChild(span); }
333
- styleSpan(span);
334
- el.style.bottom = ''; // (styleSpan places the bottom one: this one stays at the top)
333
+ var ver = P.subCfgVersion ? P.subCfgVersion() : -1;
334
+ if (span.__styled !== ver || ver === -1) { // (styled when made, and when the subtitle settings change: not every tick)
335
+ styleSpan(span);
336
+ el.style.bottom = ''; // (styleSpan places the bottom one: this one stays at the top)
337
+ span.__styled = ver;
338
+ }
335
339
  if (span.textContent !== show) { span.textContent = show; }
336
340
  }
337
341
  // Green › Second subtitle: Off, or a language the addons have (the same setting as Settings').
@@ -559,7 +563,7 @@
559
563
  // group), named in full, with the file's own tracks in the same groups as the addons'.
560
564
  // Order: the language showing now, the preferred language, then by name.
561
565
  function langMenu() {
562
- var groups = {}, order = [];
566
+ var groups = Object.create(null), order = []; // (no inherited names: a language an addon calls "__proto__" is just a key)
563
567
  function add(k, item) {
564
568
  if (!groups[k]) { groups[k] = []; order.push(k); }
565
569
  groups[k].push(item);
package/js/player.js CHANGED
@@ -11,7 +11,7 @@ var Player = (function (P) {
11
11
  getSubCfg: P.getSubCfg, setSubCfg: P.setSubCfg, getAudioCfg: P.getAudioCfg, setAudioCfg: P.setAudioCfg,
12
12
  isWatched: P.isWatched, watchedState: P.watchedState, watchedAt: P.watchedAt, watchedSince: P.watchedSince, progressFor: P.progressFor,
13
13
  addToContinue: P.addToContinue, setWatched: P.setWatched, setWatchedMany: P.setWatchedMany, clearUnwatched: P.clearUnwatched,
14
- isPrivate: P.isPrivate, setPrivate: P.setPrivate, playerCfg: P.playerCfg, setPlayerCfg: P.setPlayerCfg, langName: P.langName, lang2: P.lang2, langKey: P.langKey,
14
+ isPrivate: P.isPrivate, setPrivate: P.setPrivate, privateNow: P.privateNow, playerCfg: P.playerCfg, setPlayerCfg: P.setPlayerCfg, langName: P.langName, lang2: P.lang2, langKey: P.langKey,
15
15
  SUB_FONTS: P.SUB_FONTS, SUB_COLORS: P.SUB_COLORS, SUB_BGS: P.SUB_BGS, SUB_EDGES: P.SUB_EDGES, SUB_POSITIONS: P.SUB_POSITIONS
16
16
  };
17
17
  })(PlayerParts);
@@ -42,6 +42,9 @@ var StremioAccount = (function () {
42
42
  try { xhr.open('POST', API + method, true); } catch (e) { reject(new Error('couldn’t reach Stremio')); return; }
43
43
  xhr.timeout = 20000;
44
44
  xhr.setRequestHeader('Content-Type', 'application/json');
45
+ xhr.onprogress = function (ev) { // (an answer over MAX_BYTES: stopped as it arrives, as every other request is)
46
+ if (!done && ev && ev.loaded > MAX_BYTES) { done = true; try { xhr.abort(); } catch (e) {} reject(new Error('too large an answer')); }
47
+ };
45
48
  xhr.onreadystatechange = function () {
46
49
  if (xhr.readyState !== 4 || done) { return; }
47
50
  done = true;
package/js/stremio.js CHANGED
@@ -77,18 +77,31 @@ var Stremio = (function () {
77
77
 
78
78
  // Your order (Settings › Addons ▲▼): the higher addon is asked first wherever one answer
79
79
  // is used (a title's details: the first that has them) and listed first where several are.
80
+ // dir: -1 / 1 one place, -Infinity / Infinity to the top / bottom (U.moveIn)
80
81
  function moveAddon(url, dir) {
81
- var list = getAddonUrls(), i = list.indexOf(url), j = i + dir;
82
- if (i < 0 || j < 0 || j >= list.length) { return false; }
83
- list.splice(i, 1); list.splice(j, 0, url);
82
+ var list = getAddonUrls();
83
+ if (!U.moveIn(list, list.indexOf(url), dir)) { return false; }
84
84
  saveAddonUrls(list);
85
85
  configChanged(); // (rebuilt from the saved manifests: nothing fetched)
86
86
  return true;
87
87
  }
88
88
 
89
+ // Addons you turned off (Settings › Content › Addons, OK on one): kept in your list, in
90
+ // their place, but not loaded, so nothing is asked of them (rows, details, sources,
91
+ // subtitles, search) until they're turned on again. A backup can wait there.
92
+ var OFF_KEY = 'stremiok.addonsoff';
93
+ function offUrls() { var l = U.store.get(OFF_KEY, []); return Array.isArray(l) ? l.filter(function (u) { return typeof u === 'string'; }) : []; }
94
+ function isAddonOff(url) { return offUrls().indexOf(url) !== -1; }
95
+ function setAddonOff(url, off) {
96
+ var l = offUrls().filter(function (u) { return u !== url; });
97
+ if (off) { l.push(url); }
98
+ U.store.set(OFF_KEY, l);
99
+ configChanged(); // (loaded again without it, or with it: from the saved manifests)
100
+ }
89
101
  function removeAddon(url) {
90
102
  var list = getAddonUrls().filter(function (u) { return u !== url; });
91
103
  saveAddonUrls(list);
104
+ if (isAddonOff(url)) { U.store.set(OFF_KEY, offUrls().filter(function (u) { return u !== url; })); } // (added again: on)
92
105
  var o = originsAll();
93
106
  if (o[url]) { delete o[url]; U.store.set(ORIGIN_KEY, o); } // (added again by hand: none)
94
107
  configChanged();
@@ -329,6 +342,21 @@ var Stremio = (function () {
329
342
  var MAX_METAS = 1000, MAX_STREAMS = 500, MAX_SUBTITLES = 300, MAX_EPISODES = 5000, MAX_CATALOGS = 200;
330
343
  var _manifestProblem = {}; // url -> why it didn't load ("too large (over 2 MB)", "not an addon manifest"…)
331
344
  var _manifestDetail = {}; // url -> what the addon itself said about it, if anything (its error message)
345
+ // An addon's logo (its manifest's "logo", else "icon"), for Settings › Content › Addons: an
346
+ // http(s) link by the artwork rules (U.cardImage), or a small base64 picture written into
347
+ // the manifest, whole (one as long as STR_MAX was cut short). From an internet addon (url:
348
+ // its link; none given, taken as one), never a link into your home network (blocksLocal).
349
+ // Else null: its initial shows instead.
350
+ var LOGO_DATA = /^data:image\/(?:png|jpeg|gif|webp|svg\+xml);base64,[A-Za-z0-9+\/]+=*$/i;
351
+ function addonLogo(m, url) {
352
+ if (!m || typeof m !== 'object') { return null; }
353
+ var u = typeof m.logo === 'string' && m.logo.trim() ? m.logo : typeof m.icon === 'string' ? m.icon : '';
354
+ u = u.trim();
355
+ if (!u) { return null; }
356
+ if (/^data:/i.test(u)) { return u.length < STR_MAX && LOGO_DATA.test(u) ? u : null; }
357
+ u = U.cardImage(u, 'logo');
358
+ return u && (url ? blocksLocal({ url: url }) : true) && U.urlIsPrivate(u) ? null : u;
359
+ }
332
360
  // A manifest as the app can use it, or null: an id, and lists that are lists.
333
361
  // Its lists too: resources (a name, or { name, types, idPrefixes }), types and id prefixes
334
362
  // (text), catalogs (a type and id; extras each a name, their options text).
@@ -385,13 +413,14 @@ var Stremio = (function () {
385
413
  function loadAddons() {
386
414
  if (_loaded) { return Promise.resolve(_loaded); }
387
415
  if (_loading) { return _loading; }
388
- var urls = getAddonUrls();
416
+ var off = offUrls(), urls = getAddonUrls().filter(function (u) { return off.indexOf(u) === -1; }); // (the ones turned off: not loaded)
389
417
  var saved = U.store.get(MANIFESTS_KEY, {}) || {};
390
418
  var force = _forceNet; _forceNet = false;
391
419
  var need = urls.filter(function (u) { return force || !saved[u]; }), rev = _rev, p;
392
- // IPTV playlists load alongside the manifests (the synthetic addon is built from them).
393
- var pre = (_hasIptv && IPTV.isEnabled()) ? IPTV.load() : Promise.resolve(null);
394
- p = Promise.all([pre, need.length ? fetchManifests(need) : Promise.resolve({})]).then(function (res) {
420
+ // The IPTV playlists aren't waited for (a big one takes a while to read): the synthetic
421
+ // addon joins once they're read (iptvJoin), and onIptvReady's listeners redraw its rows.
422
+ var iptvOn = _hasIptv && IPTV.isEnabled(), iptvNow = iptvOn && (!IPTV.isReady || IPTV.isReady());
423
+ p = Promise.all([null, need.length ? fetchManifests(need) : Promise.resolve({})]).then(function (res) {
395
424
  if (_loading === p) { _loading = null; }
396
425
  if (rev !== _rev) { return loadAddons(); } // (your addons changed meanwhile: loaded as they are now)
397
426
  var got = res[1], addons = [];
@@ -401,7 +430,8 @@ var Stremio = (function () {
401
430
  });
402
431
  // Prepend so the IPTV rows sort first on Home (reorder them in Settings >
403
432
  // Appearance > Home feeds; there's no cap on how many rows or feeds).
404
- if (_hasIptv && IPTV.isEnabled()) { addons.unshift(IPTV.synthAddon()); }
433
+ if (iptvNow) { addons.unshift(IPTV.synthAddon()); }
434
+ else if (iptvOn) { iptvJoin(addons, rev); }
405
435
  if (hasYouTube()) { addons.push(YouTube.synthAddon()); } // (after your addons: its rows last on Home)
406
436
  if (typeof Trakt !== 'undefined' && Trakt.isConnected()) { addons.push(Trakt.synthAddon()); } // (your Watchlist and Up next)
407
437
  _loaded = addons;
@@ -414,6 +444,17 @@ var Stremio = (function () {
414
444
  return p;
415
445
  }
416
446
 
447
+ // The IPTV playlists, read after the addons loaded: their synthetic addon put first among them
448
+ // (its rows sort first on Home), as if it had been there, and onIptvReady's listeners told.
449
+ var _iptvReadyFns = [];
450
+ function onIptvReady(fn) { _iptvReadyFns.push(fn); }
451
+ function iptvJoin(addons, rev) {
452
+ IPTV.load().then(function () {
453
+ if (rev !== _rev || _loaded !== addons) { return; } // (your addons changed meanwhile: loaded afresh, with it)
454
+ if (!addons.some(function (a) { return a.url === 'local:iptv'; })) { addons.unshift(IPTV.synthAddon()); }
455
+ _iptvReadyFns.forEach(function (fn) { try { fn(); } catch (e) {} });
456
+ }, function () {});
457
+ }
417
458
  // Background refresh of manifests that came from the TV's saved copies. Updates the
418
459
  // loaded addons in place, so anything holding them sees the new manifest.
419
460
  function refreshSavedManifests(urls) {
@@ -668,9 +709,8 @@ var Stremio = (function () {
668
709
  visible: function () { return list().then(function (items) { return sort(items).filter(function (c) { return !isHidden(keyOf(c)); }); }); },
669
710
  move: function (key, dir) {
670
711
  return list().then(function (items) {
671
- var ordered = sort(items).map(keyOf), i = ordered.indexOf(key), j = i + dir;
672
- if (i < 0 || j < 0 || j >= ordered.length) { return; }
673
- var t = ordered[i]; ordered[i] = ordered[j]; ordered[j] = t;
712
+ var ordered = sort(items).map(keyOf);
713
+ if (!U.moveIn(ordered, ordered.indexOf(key), dir)) { return; }
674
714
  var c = cfg(); c.order = ordered; U.store.set(storeKey, c);
675
715
  });
676
716
  },
@@ -747,9 +787,7 @@ var Stremio = (function () {
747
787
  function moveRanking(key, dir) {
748
788
  var g = getRanking(), i = -1;
749
789
  g.criteria.forEach(function (c, n) { if (c.key === key) { i = n; } });
750
- var j = i + dir;
751
- if (i < 0 || j < 0 || j >= g.criteria.length) { return; }
752
- var t = g.criteria[i]; g.criteria[i] = g.criteria[j]; g.criteria[j] = t;
790
+ if (!U.moveIn(g.criteria, i, dir)) { return; }
753
791
  saveRanking(g);
754
792
  }
755
793
  function toggleRanking(key) {
@@ -802,6 +840,10 @@ var Stremio = (function () {
802
840
  return out.filter(Boolean);
803
841
  }
804
842
 
843
+ // An addon's error sent as a title's details (AIOStreams: "aiostreamserror.…", its name
844
+ // "[❌] <the addon that failed>", the error as its description; AIOMetadata: "aiom.error.…"):
845
+ // not the title, so the next addon is asked.
846
+ function isErrorMeta(m) { return /^(aiostreamserror\.|aiom\.error\.)/.test('' + (m && m.id)); }
805
847
  function getMeta(type, id, cancel) {
806
848
  if (/^ytk:/.test(id) && typeof YouTube !== 'undefined') {
807
849
  return YouTube.meta(id).then(function (m) { if (!m) { throw new Error('Not found on YouTube'); } return m; });
@@ -832,6 +874,11 @@ var Stremio = (function () {
832
874
  var url = a.base + '/meta/' + encodeURIComponent(asType[a.url] || type) + '/' + encodeURIComponent(id) + '.json';
833
875
  return U.getJSON(url, addonMs(12000), META_MAX, cancel).then(function (res) {
834
876
  var meta = fromAddon(a, res, 'title details').meta;
877
+ if (meta && typeof meta === 'object' && isErrorMeta(meta)) {
878
+ U.log('addon', ((a.manifest && a.manifest.name) || 'An addon') + ' sent an error instead of details for ' + id + ': ' +
879
+ String(meta.name || '').slice(0, 80) + (meta.description ? ' (' + String(meta.description).slice(0, 120) + ')' : ''));
880
+ return tryNext();
881
+ }
835
882
  if (meta && typeof meta === 'object') {
836
883
  if (meta.videos !== undefined) { meta.videos = oneEach(U.listOf(meta.videos, MAX_EPISODES), meta.id || id); }
837
884
  meta._from = (a.manifest && a.manifest.name) || ''; // (the title page says which addon described it)
@@ -872,8 +919,8 @@ var Stremio = (function () {
872
919
  if (e && !force && (e.pending > 0 || Date.now() - e.at < STREAM_TTL)) {
873
920
  syncLocal(e, type, id); // (the addons' answers kept; the USB drive's matches as they are now)
874
921
  if (onUpdate) {
875
- e.listeners.push(onUpdate);
876
- if (e.started) { onUpdate(e.list, e.pending); }
922
+ if (!e.done) { e.listeners.push(onUpdate); } // (still loading: told as each addon answers)
923
+ if (e.started) { onUpdate(e.list, e.pending); } // (done: told once, now, and not kept)
877
924
  }
878
925
  return e.promise.then(function () { syncLocal(e, type, id); return e.list; });
879
926
  }
@@ -902,7 +949,7 @@ var Stremio = (function () {
902
949
  return function () {
903
950
  var url = a.base + '/stream/' + encodeURIComponent(type) + '/' + encodeURIComponent(id) + '.json';
904
951
  return U.getJSON(url, addonMs(15000)).then(function (res) {
905
- var arr = U.listOf(fromAddon(a, res, 'sources').streams, MAX_STREAMS).map(youTubeSource);
952
+ var arr = U.listOf(fromAddon(a, res, 'sources').streams, MAX_STREAMS).map(youTubeSource), said = notes.length;
906
953
  arr = arr.filter(function (s) { // (an addon's notices: what it removed and why, its errors; not sources)
907
954
  if (!isNotice(s)) { return true; }
908
955
  notes.push({ addon: a.manifest.name || 'Addon', title: ('' + (s.name || '')).replace(/\s+/g, ' ').trim().slice(0, 120),
@@ -916,18 +963,23 @@ var Stremio = (function () {
916
963
  if (h) { s._headers = h; }
917
964
  });
918
965
  slots[i] = arr; okCount++;
966
+ if (!arr.length && notes.length === said) { note(a, i, 'no sources for this'); } // (its notices say why, when it sent any)
919
967
  }).then(function () { done(); }, function (err) {
920
968
  // (no answer in time: what you played from it before, marked so — see keptSources)
921
969
  if (isTimeout(err) && !slots[i].length) { slots[i] = keptSources(type, id, a, i); }
970
+ note(a, i, whyNone(err));
922
971
  done(); throw err;
923
972
  });
924
973
  };
925
974
  }), 4);
926
975
  }
976
+ // Why an addon gave nothing ("No sources found" shows it), in your addon order
977
+ function note(a, i, why) { notes.push({ addon: a.manifest.name || 'Addon', title: why, text: '', rank: i }); }
927
978
  function done() {
928
979
  e.pending--;
929
980
  e.list = local.concat([].concat.apply([], slots));
930
- _streamNotes.set(key, notes.slice(0, 6));
981
+ notes.sort(function (x, y) { return (x.rank === undefined ? -1 : x.rank) - (y.rank === undefined ? -1 : y.rank); });
982
+ _streamNotes.set(key, notes.slice(0, 10));
931
983
  notify();
932
984
  }
933
985
  // Addons that gather from others (AIOStreams…) can answer nothing while those are
@@ -942,6 +994,7 @@ var Stremio = (function () {
942
994
  return new Promise(function (r) { setTimeout(r, RETRY_EMPTY_STREAMS_MS); }).then(ask);
943
995
  }).then(function () {
944
996
  e.at = Date.now();
997
+ e.done = true; e.listeners = []; // (nothing more to tell: none kept)
945
998
  // Nothing (no answer, or no sources): not kept, so opening it again asks again.
946
999
  if (providers.length && (!okCount || fresh() <= local.length) && _streamCache[key] === e) { delete _streamCache[key]; }
947
1000
  return e.list;
@@ -954,6 +1007,16 @@ var Stremio = (function () {
954
1007
  }
955
1008
 
956
1009
  function isTimeout(err) { return /^Timeout/.test((err && err.message) || ''); }
1010
+ // Why an addon's sources didn't come, in a few words (its link, and any token in it, left out):
1011
+ // "refused (HTTP 429 · rate_limited)", "didn't answer in time", "couldn't be reached"…
1012
+ function whyNone(err) {
1013
+ var m = String((err && err.message) || ''), h = /^HTTP (\d{3}) for \S+(?::\s+(.+))?$/.exec(m); // (its message after ": ", not the link's "://")
1014
+ if (h) { return 'refused (HTTP ' + h[1] + (h[2] ? ' · ' + h[2].replace(/https?:\/\/\S+/g, '…').slice(0, 80) : '') + ')'; }
1015
+ if (isTimeout(err)) { return 'didn’t answer in time'; }
1016
+ if (/^Network error/.test(m)) { return 'couldn’t be reached'; }
1017
+ if (/^Too large/.test(m)) { return 'sent too much (' + (/\(([^)]+)\)/.exec(m) || ['', 'too large'])[1] + ')'; }
1018
+ return 'failed (' + m.replace(/https?:\/\/\S+/g, '…').slice(0, 80) + ')';
1019
+ }
957
1020
 
958
1021
  // The debrid service a source comes through, from its tag ("[TB⚡]", "[RD+]"): { tag, name } or null.
959
1022
  var DEBRIDS = { TB: 'TorBox', RD: 'Real-Debrid', AD: 'AllDebrid', PM: 'Premiumize', DL: 'Debrid-Link', ED: 'EasyDebrid', OC: 'Offcloud', PP: 'PikPak' };
@@ -1488,9 +1551,11 @@ var Stremio = (function () {
1488
1551
  var ep = parts.length >= 3 ? { season: parts[1], episode: parts[2] } : null;
1489
1552
  return { imdb: parts[0], ep: ep };
1490
1553
  }
1491
- // GET https://api.theintrodb.org/v3/media?imdb_id=tt..[&season=&episode=][&duration_ms=]
1554
+ // GET https://api.theintrodb.org/v4/media?imdb_id=tt..[&season=&episode=][&duration_ms=]
1555
+ // (v4: answers keyed by the show's TheTVDB id, episodes in TheTVDB's default order; the
1556
+ // segments as v3 gave them. v2 and v3 stop on 18 Jan 2027.)
1492
1557
  function theIntroDb(q, durationMs) {
1493
- var url = 'https://api.theintrodb.org/v3/media?imdb_id=' + encodeURIComponent(q.imdb);
1558
+ var url = 'https://api.theintrodb.org/v4/media?imdb_id=' + encodeURIComponent(q.imdb);
1494
1559
  if (q.ep) { url += '&season=' + encodeURIComponent(q.ep.season) + '&episode=' + encodeURIComponent(q.ep.episode); }
1495
1560
  if (durationMs && durationMs > 0) { url += '&duration_ms=' + Math.floor(durationMs); }
1496
1561
  return U.getJSON(url, 10000).then(function (r) {
@@ -1943,7 +2008,7 @@ var Stremio = (function () {
1943
2008
  DEFAULT_ADDONS: DEFAULT_ADDONS, prettyType: prettyType,
1944
2009
  addonUrlProblem: function (v) { return U.urlProblem(v); },
1945
2010
  getAddonUrls: getAddonUrls, manifestProblem: function (url) { return _manifestProblem[url] || ''; }, manifestDetail: function (url) { return _manifestDetail[url] || ''; }, addAddon: addAddon, removeAddon: removeAddon, moveAddon: moveAddon, streamNotes: streamNotes, keepSource: keepSource, forgetSource: forgetSource, sourceId: sourceId, hasStreamAddons: hasStreamAddons, thirdPartyNote: thirdPartyNote, refreshAddons: refreshAddons, refreshAddon: refreshAddon,
1946
- getIntegrations: getIntegrations, setIntegration: setIntegration, getPrefs: getPrefs, setPref: setPref,
2011
+ getIntegrations: getIntegrations, setIntegration: setIntegration, getPrefs: getPrefs, setPref: setPref, addonLogo: addonLogo,
1947
2012
  getIntroSegments: getIntroSegments,
1948
2013
  tmdbValidate: tmdbValidate, mdblistValidate: mdblistValidate, omdbValidate: omdbValidate,
1949
2014
  tmdbFindTvId: tmdbFindTvId, tmdbSeasonEpisodes: tmdbSeasonEpisodes, tmdbImg: tmdbImg,
@@ -1974,7 +2039,7 @@ var Stremio = (function () {
1974
2039
  streamInfo: streamInfo, formatTags: formatTags, formatKeys: formatKeys, recordFormats: recordFormats, setFormat: setFormat,
1975
2040
  resetFormats: resetFormats, badFormats: badFormats, formatList: formatList,
1976
2041
  resList: resList, setResAllowed: setResAllowed, blockedRes: blockedRes,
1977
- importSetupList: importSetupList, savedManifest: savedManifest,
2042
+ importSetupList: importSetupList, savedManifest: savedManifest, isAddonOff: isAddonOff, setAddonOff: setAddonOff, onIptvReady: onIptvReady, streamListenerCount: function (type, id) { var e = _streamCache[type + ':' + id]; return e ? e.listeners.length : 0; },
1978
2043
  iptvGetPlaylists: iptvGetPlaylists, iptvAddPlaylist: iptvAddPlaylist,
1979
2044
  iptvUpdatePlaylist: iptvUpdatePlaylist, iptvRemovePlaylist: iptvRemovePlaylist,
1980
2045
  iptvRenamePlaylist: iptvRenamePlaylist, iptvSetPlaylistOff: iptvSetPlaylistOff, iptvMovePlaylist: iptvMovePlaylist, iptvLabel: iptvLabel,
package/js/trakt.js CHANGED
@@ -92,7 +92,9 @@ var Trakt = (function () {
92
92
  try { data = text ? JSON.parse(text) : null; } catch (e2) { bad = true; }
93
93
  var retry = 0;
94
94
  try { retry = parseInt(xhr.getResponseHeader('Retry-After'), 10) || 0; } catch (e5) {}
95
- if (xhr.status >= 200 && xhr.status < 300) { resolve({ status: xhr.status, data: data, bad: bad }); }
95
+ var pages = 0, limit = 0; // (a list sent a page at a time: how many pages, how many a page)
96
+ try { pages = parseInt(xhr.getResponseHeader('X-Pagination-Page-Count'), 10) || 0; limit = parseInt(xhr.getResponseHeader('X-Pagination-Limit'), 10) || 0; } catch (e6) {}
97
+ if (xhr.status >= 200 && xhr.status < 300) { resolve({ status: xhr.status, data: data, bad: bad, pages: pages, limit: limit }); }
96
98
  else { reject({ status: xhr.status, data: data, retryAfter: retry }); }
97
99
  };
98
100
  xhr.ontimeout = function () { if (!done) { done = true; reject({ status: 0, timeout: true }); } };
@@ -338,19 +340,66 @@ var Trakt = (function () {
338
340
  return out;
339
341
  }
340
342
  function isWatched(type, id) { return watchedAt(type, id) > 0; }
343
+ // Watched past a place kept (Trakt's paused one, or this TV's): a movie watched since; an
344
+ // episode watched since, or a later one of the show (on any device). A place kept after
345
+ // those were watched (watching again) still stands. e: { type, id, updated }.
346
+ var _byShow = null, _byShowFor = null; // { imdb: [[season, episode, when]] }, built from _watched when it changes
347
+ function byShow() {
348
+ if (_byShowFor === _watched) { return _byShow; }
349
+ var m = {};
350
+ Object.keys(_watched).forEach(function (k) {
351
+ var p = /^series:(tt\d+):(\d+):(\d+)$/.exec(k), v = _watched[k];
352
+ if (p && typeof v === 'number' && v > 0) { (m[p[1]] = m[p[1]] || []).push([+p[2], +p[3], v]); }
353
+ });
354
+ _byShow = m; _byShowFor = _watched;
355
+ return m;
356
+ }
357
+ function watchedPast(e) {
358
+ var since = (e && e.updated) || 0;
359
+ if (!e || !since) { return false; }
360
+ if (e.type === 'movie') { return watchedAt('movie', e.id) > since; }
361
+ var p = /^(tt\d+):(\d+):(\d+)$/.exec('' + e.id);
362
+ if (e.type !== 'series' || !p) { return false; }
363
+ var sn = +p[2], en = +p[3];
364
+ return (byShow()[p[1]] || []).some(function (w) { return w[2] > since && (w[0] > sn || (w[0] === sn && w[1] >= en)); });
365
+ }
341
366
 
367
+ // Trakt's watched lists come a page at a time (since July 2026), and shows carry their
368
+ // episodes only when asked (extended=progress). Every page is read: up to the count Trakt
369
+ // gives, or, without one, until a page is shorter than its page size (Trakt's, or the one
370
+ // asked); PAGES_MAX at most, and no more items than max. A page that repeats the one
371
+ // before (a server that ignores the page asked) ends it too, and isn't kept twice.
372
+ var PAGES_MAX = 100;
373
+ function allPages(path, perPage, max, g) {
374
+ var out = [], page = 1, before = '';
375
+ function next() {
376
+ return authed('GET', path + (path.indexOf('?') === -1 ? '?' : '&') + 'page=' + page + '&limit=' + perPage, null).then(function (r) {
377
+ live(g);
378
+ var items = list(r.data, perPage), first = items.length ? JSON.stringify(items[0]) : '';
379
+ if (page > 1 && first && first === before) { return out.slice(0, max); }
380
+ before = first;
381
+ out = out.concat(items);
382
+ var last = !items.length || out.length >= max || page >= PAGES_MAX ||
383
+ (r.pages ? page >= r.pages : items.length < (r.limit || perPage));
384
+ if (last) { return out.slice(0, max); }
385
+ page++;
386
+ return next();
387
+ });
388
+ }
389
+ return next();
390
+ }
342
391
  var SHOWS_MAX = 5000, SEASONS_MAX = 200, EPISODES_MAX = 1000;
343
392
  function fetchWatched(g) {
344
393
  var map = {};
345
- return authed('GET', '/sync/watched/movies', null).then(function (r) {
346
- list(r.data, 20000).forEach(function (m) {
394
+ return allPages('/sync/watched/movies', 250, 20000, g).then(function (movies) {
395
+ movies.forEach(function (m) {
347
396
  var imdb = m && imdbOf(m.movie && m.movie.ids);
348
397
  if (imdb) { map['movie:' + imdb] = when(m.last_watched_at) || 1; }
349
398
  });
350
- return authed('GET', '/sync/watched/shows', null);
351
- }).then(function (r) {
399
+ return allPages('/sync/watched/shows?extended=progress', 100, SHOWS_MAX, g);
400
+ }).then(function (all) {
352
401
  var shows = [];
353
- list(r.data, SHOWS_MAX).forEach(function (sh) {
402
+ all.forEach(function (sh) {
354
403
  var show = sh && sh.show, imdb = show && imdbOf(show.ids);
355
404
  if (!imdb) { return; }
356
405
  var trakt = num(show.ids.trakt, 1e10), at = when(sh.last_watched_at);
@@ -472,13 +521,15 @@ var Trakt = (function () {
472
521
 
473
522
  // ---- playback (Continue Watching) cache ----
474
523
  // Trakt's paused titles, but not one you removed from Continue Watching here, unless Trakt
475
- // has newer progress for it since (you watched more of it, here or elsewhere).
524
+ // has newer progress for it since (you watched more of it, here or elsewhere), nor one
525
+ // you've watched past since (watchedPast: Trakt keeps a paused place it's been told of in
526
+ // other ways, a history entry, and the show's next episode would wait behind it).
476
527
  // And the shows you're watching (Trakt's Up next): their next episode, placed by when you
477
528
  // last watched the show, unless Trakt has a paused episode of it already.
478
529
  function continueList() {
479
530
  var gone = U.store.get(DISMISSED_KEY, {}) || {};
480
531
  function kept(e) { var at = gone[e.type + ':' + e.id]; return !at || (e.updated || 0) > at; }
481
- var list = (U.store.get(PLAYBACK_KEY, []) || []).filter(kept), paused = {};
532
+ var list = (U.store.get(PLAYBACK_KEY, []) || []).filter(function (e) { return kept(e) && !watchedPast(e); }), paused = {};
482
533
  list.forEach(function (e) { if (e.type === 'series') { paused[('' + e.id).split(':')[0]] = true; } });
483
534
  (U.store.get(UPNEXT_KEY, []) || []).forEach(function (u) {
484
535
  if (!u || !u.imdb || paused[u.imdb]) { return; }
@@ -837,7 +888,7 @@ var Trakt = (function () {
837
888
  return {
838
889
  hasCreds: hasCreds, isConnected: isConnected, connectStatus: connectStatus,
839
890
  startDeviceFlow: startDeviceFlow, disconnect: disconnect, refreshCaches: refreshCaches,
840
- isWatched: isWatched, watchedAt: watchedAt, watchedSince: watchedSince, continueList: continueList, dismiss: dismiss, synthAddon: synthAddon,
891
+ isWatched: isWatched, watchedAt: watchedAt, watchedSince: watchedSince, watchedPast: watchedPast, continueList: continueList, dismiss: dismiss, synthAddon: synthAddon,
841
892
  onWatched: onWatched, onMarked: onMarked, onProgress: onProgress, onStopped: onStopped, refreshSoon: refreshSoon, rate: rate, myRating: myRating,
842
893
  retryFailed: retryFailed, discardFailed: discardFailed, sendNow: sendNow, flush: flushOutbox, noteEpisodes: noteEpisodes
843
894
  };
package/js/util.js CHANGED
@@ -410,6 +410,14 @@ var U = (function () {
410
410
  are dropped), and at most `max` of them. Addons and APIs are outside our control:
411
411
  a list that's a string, or has nulls in it, or runs to a million items, must not
412
412
  stop the app. */
413
+ // Moves list[i] in place: dir -1 / 1 one step, -Infinity / Infinity to the top / bottom
414
+ // (the others keep their order). False when it can't move (not found, already there).
415
+ function moveIn(list, i, dir) {
416
+ var j = dir === -Infinity ? 0 : dir === Infinity ? list.length - 1 : i + dir;
417
+ if (i < 0 || i >= list.length || j < 0 || j >= list.length || j === i) { return false; }
418
+ list.splice(j, 0, list.splice(i, 1)[0]);
419
+ return true;
420
+ }
413
421
  function listOf(x, max, keep) { // keep(item): an extra test, applied before counting
414
422
  if (!Array.isArray(x)) { return []; }
415
423
  var out = [];
@@ -422,7 +430,14 @@ var U = (function () {
422
430
  /* GET via XHR with a timeout. Returns a Promise of the response text.
423
431
  maxBytes: give up as soon as more than that has arrived ("Too large (over …)");
424
432
  TEXT_MAX when not given. */
425
- function logged(err) { log('network', err.message); err.logged = true; return err; }
433
+ // A failed download, in Diagnostics. During a private playback, only its server: its link (a
434
+ // title's name can be in it) isn't kept, in the log or the copy saved for the next launch.
435
+ function privateNow() { try { return typeof Player !== 'undefined' && !!Player.privateNow && Player.privateNow(); } catch (e) { return false; } }
436
+ function logged(err) {
437
+ var msg = '' + err.message;
438
+ if (privateNow()) { msg = msg.replace(/(https?:\/\/[^\/\s?#]+)[^\s]*/g, '$1/…'); }
439
+ log('network', msg); err.logged = true; return err;
440
+ }
426
441
  function noteNetwork(err) { return logged(err); }
427
442
  // What a server said when it refused (an error answer's own message, e.g. AIOStreams'
428
443
  // { error: { message: "Invalid stream expression … Expected :" } }, or short plain text),
@@ -1476,7 +1491,7 @@ var U = (function () {
1476
1491
  }
1477
1492
 
1478
1493
  return {
1479
- byId: byId, el: el, clear: clear, getJSON: getJSON, getText: getText, redactUrl: redactUrl, problem: problem, problemDetail: problemDetail, listOf: listOf, lru: lru, cachedJSON: cachedJSON,
1494
+ byId: byId, el: el, clear: clear, moveIn: moveIn, getJSON: getJSON, getText: getText, redactUrl: redactUrl, problem: problem, problemDetail: problemDetail, listOf: listOf, lru: lru, cachedJSON: cachedJSON,
1480
1495
  parseUrl: parseUrl, urlProblem: urlProblem, isPlaylistUrl: isPlaylistUrl, isManifestUrl: isManifestUrl,
1481
1496
  isPrivateHost: isPrivateHost, urlIsPrivate: urlIsPrivate, setLocalPolicy: setLocalPolicy, sanitizeHeaders: sanitizeHeaders, drawable: drawable,
1482
1497
  log: log, logEntries: logEntries, clearLog: clearLog, bigStore: bigStore, diskInfo: diskInfo,
package/js/view-core.js CHANGED
@@ -191,6 +191,16 @@ var ViewParts = {};
191
191
  var cancel = U.el('div', 'pill focusable modal-btn', 'Cancel');
192
192
  var refresh = U.el('div', 'pill focusable modal-btn', 'Refresh');
193
193
  var remove = U.el('div', 'pill focusable modal-btn danger', 'Remove');
194
+ // Turn off / on: kept in your list, not asked for anything while it's off (a backup)
195
+ var isOff = !!(Stremio.isAddonOff && Stremio.isAddonOff(url));
196
+ var onoff = U.el('div', 'pill focusable modal-btn', isOff ? 'Turn on' : 'Turn off');
197
+ onoff.__onselect = function () {
198
+ closeModal();
199
+ Stremio.setAddonOff(url, !isOff);
200
+ refreshAddonRows(); // (its rows come or go; nothing else is fetched)
201
+ U.toast(isOff ? '“' + name + '” is on' : '“' + name + '” is off: not asked for anything until you turn it on', 4000);
202
+ renderSettings();
203
+ };
194
204
 
195
205
  cancel.__onselect = function () {
196
206
  closeModal();
@@ -237,6 +247,7 @@ var ViewParts = {};
237
247
  }
238
248
  row.appendChild(cancel);
239
249
  row.appendChild(refresh);
250
+ row.appendChild(onoff);
240
251
  if (configure) { row.appendChild(configure); }
241
252
  row.appendChild(share);
242
253
  row.appendChild(remove);
package/js/view-detail.js CHANGED
@@ -62,10 +62,11 @@
62
62
  U.spinner(false);
63
63
  _d.streams = list;
64
64
  var playable = list.filter(Stremio.isPlayable);
65
- if (!playable.length) { // (and why, in Settings › Diagnostics)
65
+ if (!playable.length) { // (and why: on screen, each addon's reason; in Settings › Diagnostics)
66
+ var why = list.length ? '' : (Stremio.streamNotes ? Stremio.streamNotes(type, id) : []).slice(0, 3).map(function (n) { return n.addon + ' ' + n.title; }).join('; ');
66
67
  U.log('sources', 'No playable source for “' + ((_d.meta && _d.meta.name) || id) + '” (' + type + ' ' + id + '): ' +
67
- (list.length ? list.length + ' source(s), none playable here' : 'no addon sent any'));
68
- U.toast('No playable source for this channel'); App.back(); return;
68
+ (list.length ? list.length + ' source(s), none playable here' : 'no addon sent any') + (why ? ' (' + why + ')' : ''));
69
+ U.toast('No playable source for this channel' + (why ? ': ' + why : ''), why ? 6000 : 3000); App.back(); return;
69
70
  }
70
71
  if (playable.length === 1) {
71
72
  startPlayback(playable[0], id, _d.streamLabel || _d.meta.name || 'Live');
@@ -1566,7 +1567,7 @@
1566
1567
  // offer, it's asked at once.
1567
1568
  function sourceMenu(stream) {
1568
1569
  var canStart = !howHere() && savedPlace(_d.streamId);
1569
- var canDownload = typeof Downloads !== 'undefined' && Downloads.available() && /^https?:\/\//i.test(stream.url || '') && !stream._localFile && !stream._yt;
1570
+ var canDownload = typeof Downloads !== 'undefined' && Downloads.available() && (Downloads.isFileLink ? Downloads.isFileLink(stream) : /^https?:\/\//i.test(stream.url || '')) && !stream._localFile && !stream._yt;
1570
1571
  if (!canStart && !canDownload) { toggleUnsuitable(stream); return; }
1571
1572
  var unsuit = isUnsuitable(_d.id, stream), opts = [];
1572
1573
  if (canStart) { opts.push(['start', 'Play from the start']); }