stremiok 2.0.0 → 2.2.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.
package/js/player-subs.js CHANGED
@@ -113,7 +113,7 @@
113
113
  if (mem.off) { return true; }
114
114
  if (mem.embedded) {
115
115
  if (partial) { return false; }
116
- wantEmbedded({ index: mem.index, lang: mem.lang }); _embDelay = Math.max(0, +mem.delay || 0); return true;
116
+ wantEmbedded({ index: mem.index, lang: mem.lang, nth: mem.nth }); _embDelay = Math.max(0, +mem.delay || 0); return true;
117
117
  }
118
118
  var exact =
119
119
  (mem.trackId != null ? _subTracks.find(function (t) { return t.id === mem.trackId; }) : null) ||
@@ -202,6 +202,21 @@
202
202
  var WEBVTT_ASIDE = /^(NOTE|STYLE|REGION)([ \t]|$)/;
203
203
  // Character codes (& > — …) as the characters they stand for.
204
204
  var CHAR_CODES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: '\u00a0', lrm: '\u200e', rlm: '\u200f' };
205
+ // A subtitle's tags taken out, a stray < with them: one pass, in time with the text's length
206
+ // however it's made (a run of < with no > after it used to hold the remote for seconds).
207
+ // Its text is only ever shown as text (textContent): this is for how it reads.
208
+ function noTags(t) {
209
+ var out = '', i = 0, n = t.length;
210
+ while (i < n) {
211
+ var lt = t.indexOf('<', i);
212
+ if (lt === -1) { out += t.slice(i); break; }
213
+ out += t.slice(i, lt);
214
+ var gt = t.indexOf('>', lt + 1);
215
+ if (gt === -1) { out += t.slice(lt + 1).split('<').join(''); break; } // (no tag ends after it: the rest, its < gone)
216
+ i = gt + 1;
217
+ }
218
+ return out;
219
+ }
205
220
  function charCodes(t) {
206
221
  if (t.indexOf('&') === -1) { return t; }
207
222
  return t.replace(/&(#[xX][0-9a-fA-F]{1,6}|#[0-9]{1,7}|[a-zA-Z]+);/g, function (all, c) {
@@ -213,13 +228,16 @@
213
228
  return CHAR_CODES.hasOwnProperty(c) ? CHAR_CODES[c] : all;
214
229
  });
215
230
  }
231
+ // A file's cues: CUES_MAX at most, CUE_TEXT_MAX characters of text each (a broken or hostile
232
+ // file can't hold the remote: the whole parse stays in time with the file's length).
233
+ var CUES_MAX = 20000, CUE_TEXT_MAX = 1000;
216
234
  function parseCues(text) {
217
235
  text = ('' + text).replace(/\r/g, '');
218
236
  var blocks = text.split(/\n\n+/);
219
237
  var cues = [], skip = skipper(); // (Skip lines with: your words)
220
238
  var re = /(?:(\d{1,3}):)?(\d{2}):(\d{2})[,.](\d{1,3})\s*-->\s*(?:(\d{1,3}):)?(\d{2}):(\d{2})[,.](\d{1,3})/;
221
239
  function ms(x) { return +((x + '00').slice(0, 3)) / 1000; } // (".5" is half a second)
222
- for (var i = 0; i < blocks.length; i++) {
240
+ for (var i = 0; i < blocks.length && cues.length < CUES_MAX; i++) {
223
241
  var lines = blocks[i].replace(/^\n+/, '').split('\n');
224
242
  if (WEBVTT_ASIDE.test(lines[0])) { continue; } // (a WebVTT comment, style or region: not shown)
225
243
  var ti = -1;
@@ -229,7 +247,9 @@
229
247
  if (!m) { continue; }
230
248
  var start = (+(m[1] || 0)) * 3600 + (+m[2]) * 60 + (+m[3]) + ms(m[4]);
231
249
  var end = (+(m[5] || 0)) * 3600 + (+m[6]) * 60 + (+m[7]) + ms(m[8]);
232
- var txt = skip(charCodes(lines.slice(ti + 1).join('\n').replace(/<[^>]+>/g, '').replace(/\{[^}]+\}/g, '')).replace(/\s+$/, ''));
250
+ // (tags out, a stray < with them, in one pass; then entities read: one written as &lt; is the file's own text,
251
+ // "<b>" shown as written. Never a tag: subtitles reach the screen as text only, textContent)
252
+ var txt = skip(charCodes(noTags(lines.slice(ti + 1).join('\n').slice(0, CUE_TEXT_MAX)).replace(/\{[^}]+\}/g, '')).replace(/\s+$/, ''));
233
253
  if (txt) { cues.push({ start: start, end: end, text: txt }); }
234
254
  }
235
255
  cues.sort(function (a, b) { return a.start - b.start; });
@@ -330,8 +350,12 @@
330
350
  el.className = '';
331
351
  var span = el.firstChild;
332
352
  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)
353
+ var ver = P.subCfgVersion ? P.subCfgVersion() : -1;
354
+ if (span.__styled !== ver || ver === -1) { // (styled when made, and when the subtitle settings change: not every tick)
355
+ styleSpan(span);
356
+ el.style.bottom = ''; // (styleSpan places the bottom one: this one stays at the top)
357
+ span.__styled = ver;
358
+ }
335
359
  if (span.textContent !== show) { span.textContent = show; }
336
360
  }
337
361
  // Green › Second subtitle: Off, or a language the addons have (the same setting as Settings').
@@ -380,6 +404,7 @@
380
404
  pick = tracks.find(function (t) { return t.index === w.index && (!key || langKey(textLang(t)) === key); }) || null;
381
405
  }
382
406
  if (!pick && key) { pick = tracks.find(function (t) { return langMatches(key, textLang(t)); }) || null; }
407
+ if (!pick && w.nth != null && S.ctx && S.ctx._relayCc) { pick = tracks[w.nth] || null; } // (a live channel's closed captions: in the order asked for)
383
408
  if (!pick && w.lang2) { // nothing in the default language anywhere: the second choice, a track to download first
384
409
  var key2 = langKey(w.lang2);
385
410
  var dl = _subTracks.find(function (t) { return langMatches(key2, t.lang); });
@@ -394,7 +419,7 @@
394
419
  try { avplay.setSelectTrack('TEXT', tr.index); }
395
420
  catch (e2) { try { avplay.setSelectTrack('TEXT', '' + tr.index); } catch (e3) {} }
396
421
  _embSub = tr.index; _embLang = textLang(tr);
397
- U.toast('Subtitles: ' + (_embLang || 'on') + ' (built-in)', 1800);
422
+ U.toast('Subtitles: ' + (_embLang || 'on') + (S.ctx && S.ctx._relayCc ? ' (closed captions)' : ' (built-in)'), 1800);
398
423
  }
399
424
  function embOff() {
400
425
  if (_embSub != null) { try { avplay.setSilentSubtitle(true); } catch (e) {} }
@@ -442,7 +467,7 @@
442
467
  function drawEmbedded(text, duration) {
443
468
  if (_embSub == null || _subOn) { return; }
444
469
  if (_embTimer) { clearTimeout(_embTimer); _embTimer = null; }
445
- var t = ('' + (text || '')).replace(/<br\s*\/?>/gi, '\n').replace(/<[^>]+>/g, '').replace(/\{[^}]+\}/g, '').trim();
470
+ var t = noTags(('' + (text || '')).replace(/<br\s*\/?>/gi, '\n')).replace(/\{[^}]+\}/g, '').trim();
446
471
  if (!t) { S.subsEl.className = 'hidden'; return; }
447
472
  S.subsEl.className = '';
448
473
  var span = S.subsEl.firstChild;
@@ -511,7 +536,7 @@
511
536
  replaceTop(timingMenu(topMenu().idx));
512
537
  U.toast('Subtitles: ' + fmtDelay(_subDelay) + (_subDelay > 0 ? ' (later)' : _subDelay < 0 ? ' (earlier)' : ' (as in the file)'), 1800);
513
538
  }
514
- function lineText(c) { return '“' + ('' + c.text).replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim().slice(0, 70) + '”'; }
539
+ function lineText(c) { return '“' + noTags('' + c.text).replace(/\s+/g, ' ').trim().slice(0, 70) + '”'; }
515
540
  // The next line coming up (as timed now): OK the moment it's said.
516
541
  function earMenu() {
517
542
  var t = S.cur - _subDelay, next = null;
@@ -559,12 +584,14 @@
559
584
  // group), named in full, with the file's own tracks in the same groups as the addons'.
560
585
  // Order: the language showing now, the preferred language, then by name.
561
586
  function langMenu() {
562
- var groups = {}, order = [];
587
+ var groups = Object.create(null), order = []; // (no inherited names: a language an addon calls "__proto__" is just a key)
563
588
  function add(k, item) {
564
589
  if (!groups[k]) { groups[k] = []; order.push(k); }
565
590
  groups[k].push(item);
566
591
  }
567
592
  getTextTracks().forEach(function (tr, i) { add(langKey(textLang(tr)), { emb: tr, n: i }); });
593
+ var offer = S.ctx && !S.ctx._relayCc ? S.ctx._ccOffer : null; // (a live channel's closed captions, not on yet)
594
+ (offer || []).forEach(function (c, i) { add(langKey(c.lang || ''), { cc: c, n: i, emb: true }); });
568
595
  _subTracks.forEach(function (tr) { add(langKey(tr.lang), { track: tr }); });
569
596
  var curKey = _subOn ? langKey(_subLabel) : (_embSub != null ? langKey(_embLang) : null);
570
597
  var pref = getSubCfg().lang;
@@ -579,7 +606,7 @@
579
606
  order.forEach(function (k) {
580
607
  var list = groups[k], n = list.length, emb = list.filter(function (x) { return x.emb; }).length;
581
608
  items.push({ label: langName(k),
582
- detail: n + ' option' + (n > 1 ? 's' : '') + (emb ? (emb === n ? ', in the file' : ', ' + emb + ' in the file') : ''),
609
+ detail: n + ' option' + (n > 1 ? 's' : '') + (emb ? (emb === n ? ', ' + inFile() : ', ' + emb + ' ' + inFile()) : ''),
583
610
  sel: (k === curKey),
584
611
  run: function () { pushMenu(langTracksMenu(k, list)); } });
585
612
  });
@@ -590,6 +617,7 @@
590
617
  return { title: 'Subtitle language', items: items, idx: idx, rebuild: langMenu };
591
618
  }
592
619
 
620
+ function inFile() { return S.ctx && (S.ctx._relayCc || S.ctx._ccOffer) ? 'closed captions' : 'in the file'; }
593
621
  // Level 2: the tracks in one language: the file's own first, then each addon's.
594
622
  // Picking a track keeps the menu open, the checkmark on it (an addon's once it loads), so
595
623
  // several can be tried in a row: the file's own the same as an addon's.
@@ -597,10 +625,15 @@
597
625
  var idx = 0, nAddon = 0;
598
626
  list = list.filter(function (x) { return x.emb; }).concat(list.filter(function (x) { return !x.emb; }));
599
627
  var items = list.map(function (x, i) {
628
+ if (x.cc) { // (turning a live channel's closed captions on: it restarts with them)
629
+ var c = x.cc;
630
+ return { label: 'Closed captions (' + c.ch + ')', detail: 'The channel restarts with them',
631
+ run: function () { closeMenu(); rememberSub(S.ctx.id, { embedded: true, nth: x.n, lang: c.lang }); P.ccOn(c); } };
632
+ }
600
633
  if (x.emb) {
601
634
  var tr = x.emb, l = textLang(tr);
602
635
  if (_embSub === tr.index) { idx = i; }
603
- return { label: 'Built into the file' + (l ? ' (' + l + ')' : ' · track ' + (x.n + 1)), sel: _embSub === tr.index,
636
+ return { label: (S.ctx && S.ctx._relayCc ? 'Closed captions' : 'Built into the file') + (l ? ' (' + l + ')' : ' · track ' + (x.n + 1)), sel: _embSub === tr.index,
604
637
  run: function () {
605
638
  _embDelay = 0; _embQueue = []; _embNudge = null; selectEmbeddedSub(tr); rememberSub(S.ctx.id, { embedded: true, index: tr.index, lang: l });
606
639
  if (menuOpen()) { replaceTop(langTracksMenu(key, list)); }
@@ -686,5 +719,6 @@
686
719
  P.fileHints = fileHints; P.parseCues = parseCues; P.loadTrack = loadTrack; P.subsOff = subsOff;
687
720
  P.resetSubs = resetSubs; P.closeSubs = closeSubs; P.startSubtitles = startSubtitles;
688
721
  P.subsBeforeOpen = subsBeforeOpen; P.subsOnTick = subsOnTick; P.subtitleSummary = subtitleSummary;
722
+ P.wantEmbedded = wantEmbedded;
689
723
  P.renderSubs = renderSubs; P.showEmbeddedText = showEmbeddedText; P.subConfigMenu = subConfigMenu; P.langMenu = langMenu;
690
724
  })(PlayerParts);
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();
@@ -162,7 +175,8 @@ var Stremio = (function () {
162
175
  posterRatings: p.posterRatings === true, // Settings > Appearance: IMDb rating on cards
163
176
  homePreview: p.homePreview !== false, // Settings > Appearance: Continue Watching on the TV's home bar (js/hubpreview.js)
164
177
  homePreviewOrder: p.homePreviewOrder === 'watching' ? 'watching' : 'recent', // ...its tiles: the latest first, or as in Watching
165
- hiddenTabs: wordList(p.hiddenTabs, 20, 20), // Settings > Appearance > Home tabs
178
+ hiddenTabs: wordList(p.hiddenTabs, 20, 20), // Settings > Appearance > Menu: tabs hidden
179
+ menuOrder: wordList(p.menuOrder, 20, 20), // ...and the menu's order (see ViewParts.menuOrder)
166
180
  hideWords: wordList(p.hideWords, 30, 40), // Settings > Source ranking: sources with these words at the bottom
167
181
  watchRegion: /^[A-Z]{2}$/.test(p.watchRegion || '') ? p.watchRegion : '' }; // Settings > Integrations ('' = automatic)
168
182
  }
@@ -329,6 +343,21 @@ var Stremio = (function () {
329
343
  var MAX_METAS = 1000, MAX_STREAMS = 500, MAX_SUBTITLES = 300, MAX_EPISODES = 5000, MAX_CATALOGS = 200;
330
344
  var _manifestProblem = {}; // url -> why it didn't load ("too large (over 2 MB)", "not an addon manifest"…)
331
345
  var _manifestDetail = {}; // url -> what the addon itself said about it, if anything (its error message)
346
+ // An addon's logo (its manifest's "logo", else "icon"), for Settings › Content › Addons: an
347
+ // http(s) link by the artwork rules (U.cardImage), or a small base64 picture written into
348
+ // the manifest, whole (one as long as STR_MAX was cut short). From an internet addon (url:
349
+ // its link; none given, taken as one), never a link into your home network (blocksLocal).
350
+ // Else null: its initial shows instead.
351
+ var LOGO_DATA = /^data:image\/(?:png|jpeg|gif|webp|svg\+xml);base64,[A-Za-z0-9+\/]+=*$/i;
352
+ function addonLogo(m, url) {
353
+ if (!m || typeof m !== 'object') { return null; }
354
+ var u = typeof m.logo === 'string' && m.logo.trim() ? m.logo : typeof m.icon === 'string' ? m.icon : '';
355
+ u = u.trim();
356
+ if (!u) { return null; }
357
+ if (/^data:/i.test(u)) { return u.length < STR_MAX && LOGO_DATA.test(u) ? u : null; }
358
+ u = U.cardImage(u, 'logo');
359
+ return u && (url ? blocksLocal({ url: url }) : true) && U.urlIsPrivate(u) ? null : u;
360
+ }
332
361
  // A manifest as the app can use it, or null: an id, and lists that are lists.
333
362
  // Its lists too: resources (a name, or { name, types, idPrefixes }), types and id prefixes
334
363
  // (text), catalogs (a type and id; extras each a name, their options text).
@@ -385,13 +414,14 @@ var Stremio = (function () {
385
414
  function loadAddons() {
386
415
  if (_loaded) { return Promise.resolve(_loaded); }
387
416
  if (_loading) { return _loading; }
388
- var urls = getAddonUrls();
417
+ var off = offUrls(), urls = getAddonUrls().filter(function (u) { return off.indexOf(u) === -1; }); // (the ones turned off: not loaded)
389
418
  var saved = U.store.get(MANIFESTS_KEY, {}) || {};
390
419
  var force = _forceNet; _forceNet = false;
391
420
  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) {
421
+ // The IPTV playlists aren't waited for (a big one takes a while to read): the synthetic
422
+ // addon joins once they're read (iptvJoin), and onIptvReady's listeners redraw its rows.
423
+ var iptvOn = _hasIptv && IPTV.isEnabled(), iptvNow = iptvOn && (!IPTV.isReady || IPTV.isReady());
424
+ p = Promise.all([null, need.length ? fetchManifests(need) : Promise.resolve({})]).then(function (res) {
395
425
  if (_loading === p) { _loading = null; }
396
426
  if (rev !== _rev) { return loadAddons(); } // (your addons changed meanwhile: loaded as they are now)
397
427
  var got = res[1], addons = [];
@@ -401,7 +431,8 @@ var Stremio = (function () {
401
431
  });
402
432
  // Prepend so the IPTV rows sort first on Home (reorder them in Settings >
403
433
  // Appearance > Home feeds; there's no cap on how many rows or feeds).
404
- if (_hasIptv && IPTV.isEnabled()) { addons.unshift(IPTV.synthAddon()); }
434
+ if (iptvNow) { addons.unshift(IPTV.synthAddon()); }
435
+ else if (iptvOn) { iptvJoin(addons, rev); }
405
436
  if (hasYouTube()) { addons.push(YouTube.synthAddon()); } // (after your addons: its rows last on Home)
406
437
  if (typeof Trakt !== 'undefined' && Trakt.isConnected()) { addons.push(Trakt.synthAddon()); } // (your Watchlist and Up next)
407
438
  _loaded = addons;
@@ -414,6 +445,17 @@ var Stremio = (function () {
414
445
  return p;
415
446
  }
416
447
 
448
+ // The IPTV playlists, read after the addons loaded: their synthetic addon put first among them
449
+ // (its rows sort first on Home), as if it had been there, and onIptvReady's listeners told.
450
+ var _iptvReadyFns = [];
451
+ function onIptvReady(fn) { _iptvReadyFns.push(fn); }
452
+ function iptvJoin(addons, rev) {
453
+ IPTV.load().then(function () {
454
+ if (rev !== _rev || _loaded !== addons) { return; } // (your addons changed meanwhile: loaded afresh, with it)
455
+ if (!addons.some(function (a) { return a.url === 'local:iptv'; })) { addons.unshift(IPTV.synthAddon()); }
456
+ _iptvReadyFns.forEach(function (fn) { try { fn(); } catch (e) {} });
457
+ }, function () {});
458
+ }
417
459
  // Background refresh of manifests that came from the TV's saved copies. Updates the
418
460
  // loaded addons in place, so anything holding them sees the new manifest.
419
461
  function refreshSavedManifests(urls) {
@@ -440,7 +482,18 @@ var Stremio = (function () {
440
482
  if (_hasIptv) { IPTV.removePlaylist(id); }
441
483
  configChanged();
442
484
  }
485
+ function iptvAddXtream(base, user, pass) { // (as any playlist added: the addons' list made again)
486
+ var id = _hasIptv ? IPTV.addXtream(base, user, pass) : false;
487
+ configChanged();
488
+ return id;
489
+ }
490
+ function iptvSetXtreamFormat(id, fmt) {
491
+ var changed = _hasIptv ? IPTV.setXtreamFormat(id, fmt) : false;
492
+ configChanged();
493
+ return changed;
494
+ }
443
495
  function iptvRenamePlaylist(id, name) { return _hasIptv ? IPTV.renamePlaylist(id, name) : false; }
496
+ function iptvSetPlaylistGuide(id, url) { return _hasIptv && IPTV.setPlaylistGuide ? IPTV.setPlaylistGuide(id, url) : false; }
444
497
  function iptvSetPlaylistOff(id, off) { var ok = _hasIptv ? IPTV.setPlaylistOff(id, off) : false; configChanged(); return ok; }
445
498
  function iptvMovePlaylist(id, dir) { var ok = _hasIptv ? IPTV.movePlaylist(id, dir) : false; configChanged(); return ok; }
446
499
  function iptvLabel(id) { return _hasIptv ? IPTV.label(id) : ''; }
@@ -644,7 +697,8 @@ var Stremio = (function () {
644
697
  // ---- Feed lists: order + show / hide, as the user sets them ----
645
698
  // Home feeds (the Home rows: every catalog) and search sources (the Search rows:
646
699
  // every catalog that can search) each keep their own { order: [keys], hidden: [keys] }.
647
- // Anything new (an addon added later) goes after the ordered ones, in addon order.
700
+ // Anything new (an addon added later) goes after the ordered ones, in addon order, but for a
701
+ // catalog that asks to come first (def._first: IPTV's Favorites) until you place it.
648
702
  var FEEDS_KEY = 'stremiok.feeds', SEARCH_FEEDS_KEY = 'stremiok.searchfeeds';
649
703
  function catalogKey(cat) {
650
704
  var aid = (cat.addon.manifest && cat.addon.manifest.id) || cat.addon.url;
@@ -657,8 +711,8 @@ var Stremio = (function () {
657
711
  var pos = {};
658
712
  cfg().order.forEach(function (k, i) { pos[k] = i; });
659
713
  return items.map(function (c, i) { return { c: c, i: i, k: keyOf(c) }; }).sort(function (a, b) {
660
- var pa = (pos[a.k] !== undefined) ? pos[a.k] : (100000 + a.i);
661
- var pb = (pos[b.k] !== undefined) ? pos[b.k] : (100000 + b.i);
714
+ var pa = (pos[a.k] !== undefined) ? pos[a.k] : (a.c && a.c.def && a.c.def._first) ? -1 : (100000 + a.i);
715
+ var pb = (pos[b.k] !== undefined) ? pos[b.k] : (b.c && b.c.def && b.c.def._first) ? -1 : (100000 + b.i);
662
716
  return pa - pb;
663
717
  }).map(function (x) { return x.c; });
664
718
  }
@@ -668,9 +722,8 @@ var Stremio = (function () {
668
722
  visible: function () { return list().then(function (items) { return sort(items).filter(function (c) { return !isHidden(keyOf(c)); }); }); },
669
723
  move: function (key, dir) {
670
724
  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;
725
+ var ordered = sort(items).map(keyOf);
726
+ if (!U.moveIn(ordered, ordered.indexOf(key), dir)) { return; }
674
727
  var c = cfg(); c.order = ordered; U.store.set(storeKey, c);
675
728
  });
676
729
  },
@@ -747,9 +800,7 @@ var Stremio = (function () {
747
800
  function moveRanking(key, dir) {
748
801
  var g = getRanking(), i = -1;
749
802
  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;
803
+ if (!U.moveIn(g.criteria, i, dir)) { return; }
753
804
  saveRanking(g);
754
805
  }
755
806
  function toggleRanking(key) {
@@ -802,6 +853,10 @@ var Stremio = (function () {
802
853
  return out.filter(Boolean);
803
854
  }
804
855
 
856
+ // An addon's error sent as a title's details (AIOStreams: "aiostreamserror.…", its name
857
+ // "[❌] <the addon that failed>", the error as its description; AIOMetadata: "aiom.error.…"):
858
+ // not the title, so the next addon is asked.
859
+ function isErrorMeta(m) { return /^(aiostreamserror\.|aiom\.error\.)/.test('' + (m && m.id)); }
805
860
  function getMeta(type, id, cancel) {
806
861
  if (/^ytk:/.test(id) && typeof YouTube !== 'undefined') {
807
862
  return YouTube.meta(id).then(function (m) { if (!m) { throw new Error('Not found on YouTube'); } return m; });
@@ -832,6 +887,11 @@ var Stremio = (function () {
832
887
  var url = a.base + '/meta/' + encodeURIComponent(asType[a.url] || type) + '/' + encodeURIComponent(id) + '.json';
833
888
  return U.getJSON(url, addonMs(12000), META_MAX, cancel).then(function (res) {
834
889
  var meta = fromAddon(a, res, 'title details').meta;
890
+ if (meta && typeof meta === 'object' && isErrorMeta(meta)) {
891
+ U.log('addon', ((a.manifest && a.manifest.name) || 'An addon') + ' sent an error instead of details for ' + id + ': ' +
892
+ String(meta.name || '').slice(0, 80) + (meta.description ? ' (' + String(meta.description).slice(0, 120) + ')' : ''));
893
+ return tryNext();
894
+ }
835
895
  if (meta && typeof meta === 'object') {
836
896
  if (meta.videos !== undefined) { meta.videos = oneEach(U.listOf(meta.videos, MAX_EPISODES), meta.id || id); }
837
897
  meta._from = (a.manifest && a.manifest.name) || ''; // (the title page says which addon described it)
@@ -872,8 +932,8 @@ var Stremio = (function () {
872
932
  if (e && !force && (e.pending > 0 || Date.now() - e.at < STREAM_TTL)) {
873
933
  syncLocal(e, type, id); // (the addons' answers kept; the USB drive's matches as they are now)
874
934
  if (onUpdate) {
875
- e.listeners.push(onUpdate);
876
- if (e.started) { onUpdate(e.list, e.pending); }
935
+ if (!e.done) { e.listeners.push(onUpdate); } // (still loading: told as each addon answers)
936
+ if (e.started) { onUpdate(e.list, e.pending); } // (done: told once, now, and not kept)
877
937
  }
878
938
  return e.promise.then(function () { syncLocal(e, type, id); return e.list; });
879
939
  }
@@ -902,7 +962,7 @@ var Stremio = (function () {
902
962
  return function () {
903
963
  var url = a.base + '/stream/' + encodeURIComponent(type) + '/' + encodeURIComponent(id) + '.json';
904
964
  return U.getJSON(url, addonMs(15000)).then(function (res) {
905
- var arr = U.listOf(fromAddon(a, res, 'sources').streams, MAX_STREAMS).map(youTubeSource);
965
+ var arr = U.listOf(fromAddon(a, res, 'sources').streams, MAX_STREAMS).map(youTubeSource), said = notes.length;
906
966
  arr = arr.filter(function (s) { // (an addon's notices: what it removed and why, its errors; not sources)
907
967
  if (!isNotice(s)) { return true; }
908
968
  notes.push({ addon: a.manifest.name || 'Addon', title: ('' + (s.name || '')).replace(/\s+/g, ' ').trim().slice(0, 120),
@@ -916,18 +976,23 @@ var Stremio = (function () {
916
976
  if (h) { s._headers = h; }
917
977
  });
918
978
  slots[i] = arr; okCount++;
979
+ if (!arr.length && notes.length === said) { note(a, i, 'no sources for this'); } // (its notices say why, when it sent any)
919
980
  }).then(function () { done(); }, function (err) {
920
981
  // (no answer in time: what you played from it before, marked so — see keptSources)
921
982
  if (isTimeout(err) && !slots[i].length) { slots[i] = keptSources(type, id, a, i); }
983
+ note(a, i, whyNone(err));
922
984
  done(); throw err;
923
985
  });
924
986
  };
925
987
  }), 4);
926
988
  }
989
+ // Why an addon gave nothing ("No sources found" shows it), in your addon order
990
+ function note(a, i, why) { notes.push({ addon: a.manifest.name || 'Addon', title: why, text: '', rank: i }); }
927
991
  function done() {
928
992
  e.pending--;
929
993
  e.list = local.concat([].concat.apply([], slots));
930
- _streamNotes.set(key, notes.slice(0, 6));
994
+ notes.sort(function (x, y) { return (x.rank === undefined ? -1 : x.rank) - (y.rank === undefined ? -1 : y.rank); });
995
+ _streamNotes.set(key, notes.slice(0, 10));
931
996
  notify();
932
997
  }
933
998
  // Addons that gather from others (AIOStreams…) can answer nothing while those are
@@ -942,6 +1007,7 @@ var Stremio = (function () {
942
1007
  return new Promise(function (r) { setTimeout(r, RETRY_EMPTY_STREAMS_MS); }).then(ask);
943
1008
  }).then(function () {
944
1009
  e.at = Date.now();
1010
+ e.done = true; e.listeners = []; // (nothing more to tell: none kept)
945
1011
  // Nothing (no answer, or no sources): not kept, so opening it again asks again.
946
1012
  if (providers.length && (!okCount || fresh() <= local.length) && _streamCache[key] === e) { delete _streamCache[key]; }
947
1013
  return e.list;
@@ -954,6 +1020,16 @@ var Stremio = (function () {
954
1020
  }
955
1021
 
956
1022
  function isTimeout(err) { return /^Timeout/.test((err && err.message) || ''); }
1023
+ // Why an addon's sources didn't come, in a few words (its link, and any token in it, left out):
1024
+ // "refused (HTTP 429 · rate_limited)", "didn't answer in time", "couldn't be reached"…
1025
+ function whyNone(err) {
1026
+ var m = String((err && err.message) || ''), h = /^HTTP (\d{3}) for \S+(?::\s+(.+))?$/.exec(m); // (its message after ": ", not the link's "://")
1027
+ if (h) { return 'refused (HTTP ' + h[1] + (h[2] ? ' · ' + h[2].replace(/https?:\/\/\S+/g, '…').slice(0, 80) : '') + ')'; }
1028
+ if (isTimeout(err)) { return 'didn’t answer in time'; }
1029
+ if (/^Network error/.test(m)) { return 'couldn’t be reached'; }
1030
+ if (/^Too large/.test(m)) { return 'sent too much (' + (/\(([^)]+)\)/.exec(m) || ['', 'too large'])[1] + ')'; }
1031
+ return 'failed (' + m.replace(/https?:\/\/\S+/g, '…').slice(0, 80) + ')';
1032
+ }
957
1033
 
958
1034
  // The debrid service a source comes through, from its tag ("[TB⚡]", "[RD+]"): { tag, name } or null.
959
1035
  var DEBRIDS = { TB: 'TorBox', RD: 'Real-Debrid', AD: 'AllDebrid', PM: 'Premiumize', DL: 'Debrid-Link', ED: 'EasyDebrid', OC: 'Offcloud', PP: 'PikPak' };
@@ -1392,6 +1468,57 @@ var Stremio = (function () {
1392
1468
  return tmdbGet('/' + kind + '/' + tmdbId + '/external_ids', '', cancel).then(function (r) { return (r && r.imdb_id) || null; });
1393
1469
  }
1394
1470
 
1471
+ // A film known only by a TV guide's program (the Guide's guide-only channels): { title (as
1472
+ // shown, Spanish say), orig (its original title), year } -> Promise of { type, id (IMDb), name,
1473
+ // poster } when exactly one film has that name (and that year, when known), else null.
1474
+ // TMDB with your key (the original title, else the shown one in Spanish), else Cinemeta
1475
+ // (original titles only).
1476
+ function plainTitle(s) { // ("El Señor de los Anillos:" -> "elsenordelosanillos")
1477
+ var t = ('' + (s || '')).toLowerCase();
1478
+ if (t.normalize) { try { t = t.normalize('NFD').replace(/[\u0300-\u036f]/g, ''); } catch (e) {} }
1479
+ return t.replace(/[^a-z0-9]+/g, '');
1480
+ }
1481
+ function findFilm(p, cancel) {
1482
+ var year = +p.year || 0;
1483
+ function near(y) { return !year || (y && Math.abs(y - year) <= 1); }
1484
+ function one(list, names) {
1485
+ var want = names.map(plainTitle).filter(Boolean), hits = (list || []).filter(function (r) {
1486
+ return near(r.year) && want.some(function (w) { return w === plainTitle(r.name) || w === plainTitle(r.orig); });
1487
+ });
1488
+ return hits.length === 1 ? hits[0] : null;
1489
+ }
1490
+ function tmdb(query, spanish) {
1491
+ var extra = '&query=' + encodeURIComponent(query) + (year ? '&year=' + year : '') + (spanish ? '&language=es-AR' : '');
1492
+ return tmdbGet('/search/movie', extra, cancel).then(function (r) {
1493
+ return ((r && r.results) || []).map(function (x) {
1494
+ return { tmdbId: x.id, name: x.title || '', orig: x.original_title || '', year: +('' + (x.release_date || '')).slice(0, 4) || 0, poster: tmdbImg(x.poster_path, 'w342') };
1495
+ });
1496
+ }, function () { return []; });
1497
+ }
1498
+ function viaTmdb() {
1499
+ if (!tmdbKey()) { return Promise.resolve(null); }
1500
+ var first = p.orig ? tmdb(p.orig, false).then(function (l) { return one(l, [p.orig]); }) : Promise.resolve(null);
1501
+ return first.then(function (hit) {
1502
+ return hit || (p.title ? tmdb(p.title, true).then(function (l) { return one(l, [p.title]); }) : null);
1503
+ }).then(function (hit) {
1504
+ if (!hit) { return null; }
1505
+ return tmdbImdbId('movie', hit.tmdbId, cancel).then(function (imdb) {
1506
+ return imdb ? { type: 'movie', id: imdb, name: hit.name || p.title, poster: hit.poster } : null;
1507
+ }, function () { return null; });
1508
+ });
1509
+ }
1510
+ function viaCinemeta() {
1511
+ if (!p.orig) { return Promise.resolve(null); }
1512
+ return U.getJSON('https://v3-cinemeta.strem.io/catalog/movie/top/search=' + encodeURIComponent(p.orig) + '.json', 10000, null, cancel).then(function (r) {
1513
+ var hit = one(((r && r.metas) || []).map(function (m) {
1514
+ return { id: m.id, name: m.name || '', year: parseInt(m.releaseInfo || m.year || '', 10) || 0, poster: m.poster || '' };
1515
+ }), [p.orig]);
1516
+ return hit && /^tt\d+$/.test(hit.id) ? { type: 'movie', id: hit.id, name: p.title || hit.name, poster: hit.poster } : null;
1517
+ }, function () { return null; });
1518
+ }
1519
+ return viaTmdb().then(function (hit) { return hit || viaCinemeta(); });
1520
+ }
1521
+
1395
1522
  // ---- MDBList ratings (Settings > Integrations > MDBList) ----
1396
1523
  // -> [{ key, label, text }] in a fixed order, only the ones the title has.
1397
1524
  var MDB_SOURCES = [['imdb', 'IMDb'], ['tomatoes', 'Rotten Tomatoes'], ['popcorn', 'Audience'],
@@ -1488,9 +1615,11 @@ var Stremio = (function () {
1488
1615
  var ep = parts.length >= 3 ? { season: parts[1], episode: parts[2] } : null;
1489
1616
  return { imdb: parts[0], ep: ep };
1490
1617
  }
1491
- // GET https://api.theintrodb.org/v3/media?imdb_id=tt..[&season=&episode=][&duration_ms=]
1618
+ // GET https://api.theintrodb.org/v4/media?imdb_id=tt..[&season=&episode=][&duration_ms=]
1619
+ // (v4: answers keyed by the show's TheTVDB id, episodes in TheTVDB's default order; the
1620
+ // segments as v3 gave them. v2 and v3 stop on 18 Jan 2027.)
1492
1621
  function theIntroDb(q, durationMs) {
1493
- var url = 'https://api.theintrodb.org/v3/media?imdb_id=' + encodeURIComponent(q.imdb);
1622
+ var url = 'https://api.theintrodb.org/v4/media?imdb_id=' + encodeURIComponent(q.imdb);
1494
1623
  if (q.ep) { url += '&season=' + encodeURIComponent(q.ep.season) + '&episode=' + encodeURIComponent(q.ep.episode); }
1495
1624
  if (durationMs && durationMs > 0) { url += '&duration_ms=' + Math.floor(durationMs); }
1496
1625
  return U.getJSON(url, 10000).then(function (r) {
@@ -1943,11 +2072,11 @@ var Stremio = (function () {
1943
2072
  DEFAULT_ADDONS: DEFAULT_ADDONS, prettyType: prettyType,
1944
2073
  addonUrlProblem: function (v) { return U.urlProblem(v); },
1945
2074
  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,
2075
+ getIntegrations: getIntegrations, setIntegration: setIntegration, getPrefs: getPrefs, setPref: setPref, addonLogo: addonLogo,
1947
2076
  getIntroSegments: getIntroSegments,
1948
2077
  tmdbValidate: tmdbValidate, mdblistValidate: mdblistValidate, omdbValidate: omdbValidate,
1949
2078
  tmdbFindTvId: tmdbFindTvId, tmdbSeasonEpisodes: tmdbSeasonEpisodes, tmdbImg: tmdbImg,
1950
- tmdbTitleExtras: tmdbTitleExtras, tmdbExtrasById: tmdbExtrasById, tmdbEpisodeCast: tmdbEpisodeCast, tmdbEpisodeTrailer: tmdbEpisodeTrailer, setHideWords: setHideWords, hideWordHit: hideWordHit, normalizeAddonUrl: normalizeManifestUrl, debridOf: debridOf, addonOrigin: addonOrigin, setAddonOrigin: setAddonOrigin, parseSetupList: parseSetupList, applySetupEntries: applySetupEntries, sizeLimitHit: sizeLimitHit, setSizeLimit: setSizeLimit, gbText: gbText, runtimeMinutes: runtimeMinutes, tmdbPeople: tmdbPeople, tmdbPerson: tmdbPerson, tmdbBrowse: tmdbBrowse, tmdbTitleTrailers: tmdbTitleTrailers, tmdbProviders: tmdbProviders, tmdbProvidersKept: tmdbProvidersKept, tmdbGenres: tmdbGenres, tmdbWhereToWatch: tmdbWhereToWatch, watchRegion: watchRegion, autoWatchRegion: autoWatchRegion, tmdbImdbId: tmdbImdbId, hasTmdb: function () { return !!tmdbKey(); },
2079
+ tmdbTitleExtras: tmdbTitleExtras, tmdbExtrasById: tmdbExtrasById, tmdbEpisodeCast: tmdbEpisodeCast, tmdbEpisodeTrailer: tmdbEpisodeTrailer, setHideWords: setHideWords, hideWordHit: hideWordHit, normalizeAddonUrl: normalizeManifestUrl, debridOf: debridOf, addonOrigin: addonOrigin, setAddonOrigin: setAddonOrigin, parseSetupList: parseSetupList, applySetupEntries: applySetupEntries, sizeLimitHit: sizeLimitHit, setSizeLimit: setSizeLimit, gbText: gbText, runtimeMinutes: runtimeMinutes, tmdbPeople: tmdbPeople, tmdbPerson: tmdbPerson, tmdbBrowse: tmdbBrowse, tmdbTitleTrailers: tmdbTitleTrailers, tmdbProviders: tmdbProviders, tmdbProvidersKept: tmdbProvidersKept, tmdbGenres: tmdbGenres, tmdbWhereToWatch: tmdbWhereToWatch, watchRegion: watchRegion, autoWatchRegion: autoWatchRegion, tmdbImdbId: tmdbImdbId, findFilm: findFilm, hasTmdb: function () { return !!tmdbKey(); },
1951
2080
  mdblistRatings: mdblistRatings,
1952
2081
  omdbSeasonRatings: omdbSeasonRatings,
1953
2082
  loadAddons: loadAddons, listCatalogs: listCatalogs, getCatalog: getCatalog,
@@ -1974,10 +2103,10 @@ var Stremio = (function () {
1974
2103
  streamInfo: streamInfo, formatTags: formatTags, formatKeys: formatKeys, recordFormats: recordFormats, setFormat: setFormat,
1975
2104
  resetFormats: resetFormats, badFormats: badFormats, formatList: formatList,
1976
2105
  resList: resList, setResAllowed: setResAllowed, blockedRes: blockedRes,
1977
- importSetupList: importSetupList, savedManifest: savedManifest,
2106
+ 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
2107
  iptvGetPlaylists: iptvGetPlaylists, iptvAddPlaylist: iptvAddPlaylist,
1979
2108
  iptvUpdatePlaylist: iptvUpdatePlaylist, iptvRemovePlaylist: iptvRemovePlaylist,
1980
- iptvRenamePlaylist: iptvRenamePlaylist, iptvSetPlaylistOff: iptvSetPlaylistOff, iptvMovePlaylist: iptvMovePlaylist, iptvLabel: iptvLabel,
2109
+ iptvRenamePlaylist: iptvRenamePlaylist, iptvSetPlaylistGuide: iptvSetPlaylistGuide, iptvAddXtream: iptvAddXtream, iptvSetXtreamFormat: iptvSetXtreamFormat, iptvSetPlaylistOff: iptvSetPlaylistOff, iptvMovePlaylist: iptvMovePlaylist, iptvLabel: iptvLabel,
1981
2110
  iptvLoad: iptvLoad, iptvStatus: iptvStatus, iptvReload: iptvReload
1982
2111
  };
1983
2112
  })();