stremiok 2.1.0 → 2.2.1

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/epg.js ADDED
@@ -0,0 +1,320 @@
1
+ /* TV guide (XMLTV) for IPTV channels. Global namespace: EPG
2
+ Which guide a playlist has (guideUrl: the Guide link you set, else the one the playlist
3
+ names, else an Xtream playlist's own), read by StremioK's service for your channels only
4
+ (their keys as a bitset: see service/index.js, epgControl), and the programs asked for as
5
+ the cards, Home's details strip, the player and a channel's schedule need them. Not in a
6
+ desktop browser (no service). */
7
+ var EPG = (function () {
8
+ 'use strict';
9
+
10
+ // A channel's key, as the service makes it: "i:" and its tvg-id (iptv-org's feed after an @
11
+ // left out: "ElTrece.ar@SD" is a guide's "ElTrece.ar"), or "n:" and its name without
12
+ // capitals, accents, spaces, "TV" or HD / FHD / 4K tags ("El Trece HD": "n:eltrece"). A
13
+ // country at the end (".ar", or a name's " ARG" / " MX": COUNTRY) is kept apart, after a dot
14
+ // ("FOX SPORTS 2 ARG": "n:foxsports2.ar"); a guide's channel answers with it and without
15
+ // (keysAll), so "A24" finds a guide's "A24.ar", and "ESPN 2 MX" doesn't find "Espn2.ar".
16
+ var ACCENTS = { 'á': 'a', 'à': 'a', 'â': 'a', 'ä': 'a', 'ã': 'a', 'å': 'a', 'é': 'e', 'è': 'e', 'ê': 'e', 'ë': 'e', 'í': 'i', 'ì': 'i', 'î': 'i', 'ï': 'i',
17
+ 'ó': 'o', 'ò': 'o', 'ô': 'o', 'ö': 'o', 'õ': 'o', 'ú': 'u', 'ù': 'u', 'û': 'u', 'ü': 'u', 'ñ': 'n', 'ç': 'c' };
18
+ var COUNTRY = { ar: 'ar', arg: 'ar', mx: 'mx', mex: 'mx', us: 'us', usa: 'us', uk: 'uk', gb: 'uk', es: 'es', br: 'br', cl: 'cl', co: 'co', pe: 'pe',
19
+ uy: 'uy', py: 'py', ve: 've', ec: 'ec', bo: 'bo', ca: 'ca', fr: 'fr', de: 'de', it: 'it', pt: 'pt' };
20
+ function parts(kind, s) {
21
+ var t = ('' + (s || '')).toLowerCase().replace(/[áàâäãåéèêëíìîïóòôöõúùûüñç]/g, function (c) { return ACCENTS[c]; }).trim(), cc = '', m;
22
+ if (kind === 'i') { t = t.replace(/@.*$/, ''); }
23
+ if ((m = /\.([a-z]{2})$/.exec(t))) { cc = m[1]; t = t.slice(0, -3); }
24
+ else if (kind === 'n' && (m = /[\s_-]+([a-z]{2,3})$/.exec(t)) && COUNTRY[m[1]] && t.length > m[0].length) { cc = COUNTRY[m[1]]; t = t.slice(0, -m[0].length); }
25
+ if (kind === 'n') { t = t.replace(/\[[^\]]*\]|\([^)]*\)/g, ' ').replace(/\b(hd|fhd|uhd|sd|hq|4k|8k|hevc|h265|1080p?|720p?|tv)\b/g, ' '); }
26
+ t = t.replace(/[^a-z0-9]+/g, '');
27
+ if (kind === 'n' && t.length > 4) { t = t.replace(/tv$/, ''); } // ("CronicaTV")
28
+ return { base: t.slice(0, 60), cc: cc };
29
+ }
30
+ function key(kind, s) { var p = parts(kind, s); return p.base ? kind + ':' + p.base + (p.cc ? '.' + p.cc : '') : ''; }
31
+ function keysAll(kind, s) { var p = parts(kind, s); return !p.base ? [] : p.cc ? [kind + ':' + p.base + '.' + p.cc, kind + ':' + p.base] : [kind + ':' + p.base]; }
32
+ function hash(s) {
33
+ var h = 0x811c9dc5;
34
+ for (var i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0; }
35
+ return h;
36
+ }
37
+ // The keys as a bitset, base64url: 16 bits a key (1,024 to 131,072 in all); a key's hash
38
+ // picks its bit.
39
+ var B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
40
+ function bitsFor(keys) {
41
+ var n = 1024, bytes = [], i;
42
+ while (n < keys.length * 16 && n < 131072) { n *= 2; }
43
+ for (i = 0; i < n / 8; i++) { bytes.push(0); }
44
+ keys.forEach(function (k) { if (k) { var b = hash(k) % n; bytes[b >> 3] |= 1 << (b & 7); } });
45
+ var out = '';
46
+ for (i = 0; i < bytes.length; i += 3) {
47
+ var v = (bytes[i] << 16) | ((bytes[i + 1] || 0) << 8) | (bytes[i + 2] || 0);
48
+ out += B64.charAt((v >> 18) & 63) + B64.charAt((v >> 12) & 63) + (i + 1 < bytes.length ? B64.charAt((v >> 6) & 63) : '') + (i + 2 < bytes.length ? B64.charAt(v & 63) : '');
49
+ }
50
+ return out;
51
+ }
52
+ function keysOf(c) { return [c.tvgId ? key('i', c.tvgId) : '', key('n', c.name)].filter(Boolean); }
53
+
54
+ // A playlist's guide: yours (p.guide), else the first the playlist names (url-tvg), else an
55
+ // Xtream get.php link's xmltv.php (the same login; also in place of a guide the playlist
56
+ // names on its own server without the login). '' when none.
57
+ function param(q, name) {
58
+ var m = new RegExp('(?:^|&)' + name + '=([^&#]*)').exec(q || '');
59
+ if (!m) { return ''; }
60
+ try { return decodeURIComponent(m[1].replace(/\+/g, ' ')); } catch (e) { return ''; }
61
+ }
62
+ function guideUrl(p, named) {
63
+ if (p && p.guide) { return p.guide; }
64
+ var g = (named || []).filter(function (u) { return !!U.parseUrl(u) && /^https?:\/\//i.test(u); })[0];
65
+ var u = U.parseUrl(p && p.url), xt = '';
66
+ if (u && /\/get\.php$/i.test(u.path)) {
67
+ var user = param(u.query, 'username'), pass = param(u.query, 'password');
68
+ if (user && pass) { xt = u.scheme + '://' + u.host + (u.port ? ':' + u.port : '') + u.path.replace(/get\.php$/i, 'xmltv.php') + '?username=' + encodeURIComponent(user) + '&password=' + encodeURIComponent(pass); }
69
+ }
70
+ var gu = g && U.parseUrl(g);
71
+ if (g && xt && gu && gu.host === u.host && gu.port === u.port && !param(gu.query, 'username')) { return xt; } // (its own server's, without the login)
72
+ return g || xt;
73
+ }
74
+
75
+ function available() { return typeof YTPlay !== 'undefined' && !!YTPlay.epgLoad && YTPlay.installMode() !== 'browser'; }
76
+
77
+ // ---- the guides: asked to read, then waited for ----
78
+ var _guides = {}; // url -> { k, state, at, channels, programmes, partial, error, timer }
79
+ var _byList = {}; // playlist id -> its guide's url ('' none)
80
+ var POLL_MS = [300, 300, 600, 1000, 2000], POLL_MS_LATER = 3000, POLLS = 300, CHECK_MS = 60 * 60 * 1000;
81
+ // Every playlist's guide asked for (again: the service reads one only when it's old), for
82
+ // its channels' keys. After the playlists are read, and every hour. again: a playlist's id,
83
+ // or true for all (its Reload, IPTV.reload): their guides read again now, however new.
84
+ function start(again) {
85
+ if (!available() || typeof IPTV === 'undefined' || !IPTV.channels) { return; }
86
+ var byGuide = {}, force = {};
87
+ _byList = {};
88
+ IPTV.getPlaylists().forEach(function (p) {
89
+ if (p.off) { return; }
90
+ var st = IPTV.status(p.id), url = guideUrl(p, st && st.guides);
91
+ _byList[p.id] = url;
92
+ if (url && !byGuide[url]) { byGuide[url] = []; }
93
+ if (url && (again === true || again === p.id)) { force[url] = true; }
94
+ });
95
+ IPTV.channels().forEach(function (c) {
96
+ var url = _byList[c._playlistId];
97
+ if (url) { byGuide[url].push.apply(byGuide[url], keysOf(c)); }
98
+ });
99
+ var gone = false;
100
+ Object.keys(_guides).forEach(function (url) { // (a guide no playlist uses any more: dropped)
101
+ if (byGuide[url]) { return; }
102
+ if (_guides[url].timer) { clearTimeout(_guides[url].timer); }
103
+ delete _guides[url];
104
+ forget(url);
105
+ gone = true;
106
+ });
107
+ Object.keys(byGuide).forEach(function (url) { if (byGuide[url].length) { loadGuide(url, bitsFor(byGuide[url]), !!force[url]); } });
108
+ if (!_checker) { _checker = setInterval(function () { start(); }, CHECK_MS); }
109
+ if (gone) { changed(); }
110
+ }
111
+ var _checker = null;
112
+ function loadGuide(url, k, force) {
113
+ var g = _guides[url];
114
+ if (!g || g.k !== k || force) { if (g && g.timer) { clearTimeout(g.timer); } g = _guides[url] = { k: k, state: 'loading' }; }
115
+ else if (g.timer || g.asking) { return; }
116
+ (function ask(n) {
117
+ g.asking = true;
118
+ YTPlay.epgLoad(url, k, force && !n).then(function (r) {
119
+ g.asking = false;
120
+ if (_guides[url] !== g) { return; }
121
+ var was = g.state + '|' + g.at;
122
+ ['state', 'at', 'channels', 'programmes', 'partial', 'error'].forEach(function (f) { g[f] = r && r[f]; });
123
+ if (g.state === 'loading' && n < POLLS) { g.timer = setTimeout(function () { g.timer = null; ask(n + 1); }, n < POLL_MS.length ? POLL_MS[n] : POLL_MS_LATER); }
124
+ if (g.state + '|' + g.at !== was) {
125
+ if (g.state === 'ready') { forget(url); } // (read again: what's on, asked again)
126
+ changed();
127
+ }
128
+ }, function () {
129
+ g.asking = false;
130
+ if (_guides[url] !== g) { return; }
131
+ g.state = 'failed'; g.error = 'StremioK’s service didn’t answer';
132
+ changed();
133
+ });
134
+ })(0);
135
+ }
136
+ // For Settings › IPTV: a playlist's guide and how it stands, or null when it has none.
137
+ function status(playlistId) {
138
+ var url = _byList[playlistId];
139
+ if (!url) { return null; }
140
+ var g = _guides[url] || { state: 'loading' };
141
+ return { url: url, state: g.state, at: g.at || 0, channels: g.channels || 0, programmes: g.programmes || 0, partial: !!g.partial, error: g.error || '' };
142
+ }
143
+
144
+ // ---- programs: asked for in batches, kept per channel ----
145
+ var _progs = {}; // channel id -> { at, list: [{ start, stop, title, desc }], full (the whole window) }
146
+ var _icons = {}; // channel id -> its guide's logo (for one the playlist gives none: logo)
147
+ var _want = {}, _wantTimer = null, _asking = 0, BATCH = 60, PROG_MS = 30 * 60 * 1000;
148
+ function channelOf(id) { return (typeof IPTV !== 'undefined' && IPTV.channel) ? IPTV.channel(id) : null; }
149
+ function guideOf(c) { var url = c && _byList[c._playlistId], g = url && _guides[url]; return (g && g.state === 'ready') ? url : ''; }
150
+ function forget(url) { Object.keys(_progs).forEach(function (id) { if (_progs[id].url === url) { delete _progs[id]; } }); }
151
+ function want(ids) {
152
+ (ids || []).forEach(function (id) { if (/^iptv:/.test(id || '')) { _want[id] = 1; } });
153
+ if (!_wantTimer) { _wantTimer = setTimeout(flush, 50); }
154
+ }
155
+ function nowSec() { return Math.floor(Date.now() / 1000); }
156
+ function asList(rows, url) {
157
+ return (rows || []).filter(function (r) { return r && r.length >= 3 && r[1] > r[0]; })
158
+ .map(function (r) {
159
+ var p = { start: +r[0], stop: +r[1], title: '' + r[2], desc: '' + (r[3] || ''), picture: (typeof r[4] === 'string' && U.parseUrl(r[4]) && !(U.urlIsPrivate(r[4]) && !U.urlIsPrivate(url || ''))) ? r[4] : '' }; // (no picture into your home network from a guide on the internet)
160
+ var a = r[5]; // (what it is, from the guide's categories: a film's original title and year)
161
+ if (a && typeof a === 'object') {
162
+ if (a.m === 'movie' || a.m === 'series') { p.kind = a.m; }
163
+ if (typeof a.o === 'string' && a.o) { p.orig = a.o.slice(0, 120); }
164
+ if (+a.y >= 1900 && +a.y <= 2100) { p.year = +a.y; }
165
+ }
166
+ return p;
167
+ });
168
+ }
169
+ // One request a guide at a time per batch (BATCH channels): from now, the next few.
170
+ function flush() {
171
+ _wantTimer = null;
172
+ if (_asking) { _wantTimer = setTimeout(flush, 200); return; }
173
+ var byGuide = {};
174
+ Object.keys(_want).forEach(function (id) {
175
+ var c = channelOf(id), url = guideOf(c);
176
+ if (!url) { return; } // (no guide, or not read yet: asked again once it is)
177
+ delete _want[id];
178
+ if (fresh(id)) { return; }
179
+ (byGuide[url] = byGuide[url] || []).push(c);
180
+ });
181
+ Object.keys(byGuide).forEach(function (url) {
182
+ var list = byGuide[url];
183
+ for (var i = 0; i < list.length; i += BATCH) { ask(url, list.slice(i, i + BATCH), nowSec(), nowSec() + 6 * 3600, 4, false); }
184
+ });
185
+ }
186
+ function ask(url, chans, from, to, max, full) {
187
+ var keys = [], gen = _gen;
188
+ chans.forEach(function (c) { keys.push.apply(keys, keysOf(c)); });
189
+ _asking++;
190
+ // Resolves { id: programs } for the channels answered ([] when none, or not in the guide),
191
+ // and _unknown: the ids not answered (failed, cut off, not read there, or the guides
192
+ // changed since): kept as they were and asked again, never taken as empty.
193
+ function unknown() { var o = {}; o._unknown = chans.map(function (c) { return c.id; }); return o; }
194
+ return YTPlay.epgGet(url, keys, from, to, max).then(function (r) {
195
+ _asking--;
196
+ if (gen !== _gen || !r || typeof r !== 'object' || r._unread) { return unknown(); } // (the guides changed since, or no answer to read)
197
+ var cut = {};
198
+ (Array.isArray(r._cut) ? r._cut : []).forEach(function (k) { cut[k] = 1; });
199
+ var out = {}, icons = (r._icons && typeof r._icons === 'object') ? r._icons : {};
200
+ var unk = [];
201
+ chans.forEach(function (c) {
202
+ if (_byList[c._playlistId] !== url) { unk.push(c.id); return; } // (its playlist's guide is another now)
203
+ var ks = keysOf(c), rows = null;
204
+ for (var i = 0; i < ks.length && !rows; i++) { if (Array.isArray(r[ks[i]])) { rows = r[ks[i]]; } }
205
+ if (!rows && ks.some(function (k) { return cut[k]; })) { unk.push(c.id); return; } // (cut off: not answered)
206
+ for (var j = 0; j < ks.length; j++) {
207
+ var icon = icons[ks[j]];
208
+ // (a guide on the internet can't point one into your home network)
209
+ if (typeof icon === 'string' && U.parseUrl(icon) && !(U.urlIsPrivate(icon) && !U.urlIsPrivate(url))) { _icons[c.id] = icon; break; }
210
+ }
211
+ var e = { at: Date.now(), url: url, list: asList(rows, url), full: full };
212
+ if (!full && _progs[c.id] && _progs[c.id].full && _progs[c.id].at > Date.now() - PROG_MS) { return; } // (a whole schedule kept)
213
+ _progs[c.id] = e;
214
+ out[c.id] = e.list;
215
+ });
216
+ trimProgs();
217
+ if (unk.length) { out._unknown = unk; }
218
+ changed();
219
+ return out;
220
+ }, function () { _asking--; return unknown(); });
221
+ }
222
+ // Programs kept for PROGS_KEEP channels at most (the ones read longest ago go first): a long
223
+ // session going through thousands of channels doesn't keep them all.
224
+ var PROGS_KEEP = 400;
225
+ function trimProgs() {
226
+ var ids = Object.keys(_progs);
227
+ if (ids.length <= PROGS_KEEP) { return; }
228
+ ids.sort(function (a, b) { return _progs[a].at - _progs[b].at; }).slice(0, ids.length - PROGS_KEEP).forEach(function (id) { delete _progs[id]; });
229
+ }
230
+ function fresh(id) {
231
+ var e = _progs[id];
232
+ if (!e || Date.now() - e.at > PROG_MS) { return false; }
233
+ var t = nowSec();
234
+ return !e.list.length || e.list[e.list.length - 1].stop > t; // (what's on, still in what was read)
235
+ }
236
+ // What's on now, and next: { now, next } (either may be null), or null without a guide.
237
+ function now(id) {
238
+ var c = channelOf(id);
239
+ if (!c || !guideOf(c)) { return null; }
240
+ if (!fresh(id)) { want([id]); }
241
+ var e = _progs[id];
242
+ if (!e || e.url !== guideOf(c)) { return null; }
243
+ var t = nowSec(), cur = null, next = null;
244
+ for (var i = 0; i < e.list.length; i++) {
245
+ var p = e.list[i];
246
+ if (!cur && p.start <= t && t < p.stop) { cur = p; continue; }
247
+ if (p.start > t && (!next || p.start < next.start)) { next = p; break; }
248
+ }
249
+ return (cur || next) ? { now: cur, next: next } : null;
250
+ }
251
+ // A channel's schedule, from 2 hours ago to tomorrow: Promise of [{ start, stop, title, desc }].
252
+ function schedule(id) {
253
+ var c = channelOf(id), url = guideOf(c);
254
+ if (!url) { return Promise.resolve([]); }
255
+ return ask(url, [c], nowSec() - 2 * 3600, nowSec() + 30 * 3600, 150, true).then(function (out) { return out[id] || []; });
256
+ }
257
+ function hasGuide(id) { return !!guideOf(channelOf(id)); }
258
+ // A channel's logo from its guide ('' when it gave none, or not asked yet).
259
+ function logo(id) { return _icons[id] || ''; }
260
+ // Whether any guide was read with channels in it (the Guide tab shows then), and the channels
261
+ // that have a guide, in the IPTV tab's order.
262
+ function hasAny() {
263
+ return Object.keys(_byList).some(function (id) { var g = _guides[_byList[id]]; return !!g && g.state === 'ready' && g.channels > 0; });
264
+ }
265
+ function guided() { // (your favorites first)
266
+ var all = (typeof IPTV !== 'undefined' && IPTV.channels ? IPTV.channels() : []).filter(function (c) { return !!guideOf(c); });
267
+ var fav = IPTV.isFavorite ? all.filter(function (c) { return IPTV.isFavorite(c.id); }) : [];
268
+ return fav.concat(all.filter(function (c) { return fav.indexOf(c) === -1; }));
269
+ }
270
+ // Many channels' programs from 'from' to 'to' (the Guide tab): Promise of { id: programs },
271
+ // RANGE_BATCH channels a request (each answer kept to 2 MB by the service: 150 programs
272
+ // a channel with their pictures and descriptions fit).
273
+ var RANGE_BATCH = 20;
274
+ function range(ids, from, to) {
275
+ var byGuide = {}, jobs = [];
276
+ (ids || []).forEach(function (id) { var c = channelOf(id), url = guideOf(c); if (url) { (byGuide[url] = byGuide[url] || []).push(c); } });
277
+ Object.keys(byGuide).forEach(function (url) {
278
+ var list = byGuide[url];
279
+ for (var i = 0; i < list.length; i += RANGE_BATCH) { jobs.push(ask(url, list.slice(i, i + RANGE_BATCH), Math.floor(from), Math.ceil(to), 150, true)); }
280
+ });
281
+ return Promise.all(jobs).then(function (outs) {
282
+ var all = {}, unknown = [];
283
+ outs.forEach(function (o) { Object.keys(o || {}).forEach(function (id) { if (id === '_unknown') { unknown = unknown.concat(o._unknown); } else { all[id] = o[id]; } }); });
284
+ if (unknown.length) { all._unknown = unknown; } // (not answered: see ask)
285
+ return all;
286
+ });
287
+ }
288
+
289
+ // 20:30, or with the day when it isn't today (Tue 01:00).
290
+ var DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
291
+ function two(n) { return (n < 10 ? '0' : '') + n; }
292
+ function hhmm(sec) { var d = new Date(sec * 1000); return two(d.getHours()) + ':' + two(d.getMinutes()); }
293
+ function dayTime(sec) {
294
+ var d = new Date(sec * 1000), today = new Date(Date.now());
295
+ var same = d.getFullYear() === today.getFullYear() && d.getMonth() === today.getMonth() && d.getDate() === today.getDate();
296
+ return (same ? '' : DAYS[d.getDay()] + ' ') + hhmm(sec);
297
+ }
298
+
299
+ // Told when a guide is read or programs arrive (once for a burst).
300
+ var _fns = [], _changeTimer = null;
301
+ function onChange(fn) { _fns.push(fn); }
302
+ function changed() {
303
+ if (_changeTimer) { return; }
304
+ _changeTimer = setTimeout(function () {
305
+ _changeTimer = null;
306
+ if (Object.keys(_want).length && !_wantTimer) { _wantTimer = setTimeout(flush, 50); } // (asked before the guide was read)
307
+ _fns.forEach(function (fn) { try { fn(); } catch (e) {} });
308
+ }, 60);
309
+ }
310
+ // The guide links changed (a playlist's Guide link, its link, its channels): asked again.
311
+ // An answer asked before (an older _gen) is dropped when it comes.
312
+ var _gen = 0;
313
+ function reset() { _gen++; _progs = {}; _icons = {}; _want = {}; start(); }
314
+
315
+ return {
316
+ generation: function () { return _gen; }, // (changes when the guides do: what was read before isn't theirs)
317
+ key: key, keysAll: keysAll, bitsFor: bitsFor, guideUrl: guideUrl, available: available, start: start, reset: reset, status: status,
318
+ want: want, now: now, schedule: schedule, hasGuide: hasGuide, logo: logo, hasAny: hasAny, guided: guided, range: range, onChange: onChange, hhmm: hhmm, dayTime: dayTime
319
+ };
320
+ })();