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.
- package/README.md +4 -2
- package/config.xml +1 -1
- package/css/style.css +10 -2
- package/index.html +31 -31
- package/js/downloads.js +17 -5
- package/js/hubpreview.js +15 -5
- package/js/iptv.js +4 -4
- package/js/player-controls.js +5 -0
- package/js/player-core.js +92 -19
- package/js/player-lang.js +1 -1
- package/js/player-quality.js +15 -7
- package/js/player-store.js +7 -3
- package/js/player-subs.js +7 -3
- package/js/player.js +1 -1
- package/js/stremio-account.js +3 -0
- package/js/stremio.js +87 -22
- package/js/trakt.js +60 -9
- package/js/util.js +17 -2
- package/js/view-core.js +11 -0
- package/js/view-detail.js +5 -4
- package/js/view-home.js +27 -10
- package/js/view-settings.js +67 -45
- package/js/youtube.js +1 -3
- package/js/ytplay.js +10 -1
- package/package.json +1 -1
- package/service/index.js +280 -74
- package/service/preview.js +14 -4
package/js/player-store.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
334
|
-
|
|
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 =
|
|
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);
|
package/js/stremio-account.js
CHANGED
|
@@ -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()
|
|
82
|
-
if (
|
|
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
|
|
393
|
-
|
|
394
|
-
|
|
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 (
|
|
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)
|
|
672
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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/
|
|
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/
|
|
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
|
-
|
|
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
|
|
346
|
-
|
|
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
|
|
351
|
-
}).then(function (
|
|
399
|
+
return allPages('/sync/watched/shows?extended=progress', 100, SHOWS_MAX, g);
|
|
400
|
+
}).then(function (all) {
|
|
352
401
|
var shows = [];
|
|
353
|
-
|
|
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
|
-
|
|
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']); }
|