nuvio-tizen 1.8.12 → 1.9.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 CHANGED
@@ -47,6 +47,26 @@ core behaviour, targeting 2017 Samsung TVs (Tizen 3.0, ~Chromium 47).
47
47
  - Settings: add addons on a **dedicated page** (field kept above the on-screen keyboard;
48
48
  nothing is saved unless you press Confirm), remove with a confirmation prompt, and
49
49
  **Reset to default addons**.
50
+ - **Live TV (IPTV)**: point Settings → *Live TV (IPTV)* at an **M3U/M3U8 playlist URL**.
51
+ Channels are grouped by `group-title` into `tv`-type **Live TV** rows on Home (searchable,
52
+ orderable like any feed). **Direct HTTP(S) HLS** channels play natively via AVPlay
53
+ (per-channel `http-user-agent` / `http-referrer` from the playlist are applied best-effort).
54
+ **YouTube** channels (`youtube.com/...`) play via YouTube's own **leanback TV app** behind a
55
+ bundled localhost proxy — see below.
56
+
57
+ ### YouTube (leanback) channels
58
+
59
+ M3U entries that point at `youtube.com`/`youtu.be` can't be turned into a direct stream URL
60
+ on-device (YouTube blocks that) and `youtube.com/tv` can't be framed directly
61
+ (`X-Frame-Options`). Nuvio bundles a small **localhost proxy service** (`service/index.js`,
62
+ a `<tizen:service>` on `127.0.0.1:8099`) that serves `youtube.com/tv` with `X-Frame-Options`
63
+ and CSP stripped, rewrites media/asset URLs, and injects an ad-block userscript — then Nuvio
64
+ plays it in a fullscreen leanback overlay. Press **RETURN/Back** to return to Nuvio.
65
+
66
+ > Credit: the proxy design, URL-rewrite rules and the injected ad-block userscript are from
67
+ > **[TizenTube](https://github.com/KrX3D/TizenTube)** (`@krx3d/tizentube2`, MIT). Nuvio ports a
68
+ > trimmed, dependency-free (Node-core only) version of its standalone proxy and additionally
69
+ > strips `X-Frame-Options` so it can run inside an in-app overlay.
50
70
 
51
71
  > **Note on WatchHub:** it's a "where to watch" addon — its results are Rent/Buy/Subscription
52
72
  > *links to external services*, not direct video URLs, so they show as non-playable. Add your
@@ -64,6 +84,10 @@ come from whatever **stream addons you add yourself** in Settings. Streams that
64
84
  a torrent `infoHash` (no HTTP URL) are listed but not playable — AVPlay needs a direct
65
85
  HTTP(S)/HLS/DASH URL, and this app embeds no torrent/debrid engine.
66
86
 
87
+ The **IPTV** feature likewise hosts no content: you supply your own M3U playlist URL. The
88
+ bundled leanback proxy only relays requests to YouTube's own servers on the TV's behalf (to
89
+ strip framing headers); it stores nothing and adds no content.
90
+
67
91
  ---
68
92
 
69
93
  ## Install path A — TizenBrew (recommended, no cert signing)
package/config.xml CHANGED
@@ -1,17 +1,28 @@
1
1
  <?xml version="1.0" encoding="UTF-8"?>
2
2
  <widget xmlns="http://www.w3.org/ns/widgets" xmlns:tizen="http://tizen.org/ns/widgets"
3
- id="http://nuvio.tizen/NuvioTizen" version="1.8.12" viewmodes="maximized">
4
- <tizen:application id="Nuvio00001.Nuvio" package="Nuvio00001" required_version="2.4"/>
3
+ id="http://nuvio.tizen/NuvioTizen" version="1.9.0" viewmodes="maximized">
4
+ <tizen:application id="Nuvio00001.Nuvio" package="Nuvio00001" required_version="3.0"/>
5
5
  <content src="index.html"/>
6
6
  <feature name="http://tizen.org/feature/screen.size.all"/>
7
+ <!-- Node service (leanback proxy) -->
8
+ <feature name="http://tizen.org/feature/web.service"/>
7
9
  <icon src="icon.png"/>
8
10
  <name>Nuvio</name>
9
11
  <tizen:privilege name="http://tizen.org/privilege/internet"/>
10
12
  <tizen:privilege name="http://tizen.org/privilege/tv.inputdevice"/>
11
13
  <tizen:privilege name="http://tizen.org/privilege/application.launch"/>
12
- <!-- Cross-origin XHR to any addon host -->
14
+ <tizen:privilege name="http://developer.samsung.com/privilege/network.public"/>
15
+ <!-- Cross-origin XHR to any addon host; framing the localhost leanback proxy -->
13
16
  <access origin="*" subdomains="true"/>
17
+ <tizen:allow-navigation href="*"/>
14
18
  <tizen:profile name="tv"/>
19
+ <!-- Bundled leanback proxy service (see service/index.js). Launched on demand
20
+ by js/ytplay.js to serve youtube.com/tv with X-Frame-Options/CSP stripped. -->
21
+ <tizen:service id="Nuvio00001.NuvioProxy">
22
+ <tizen:content src="service/index.js"/>
23
+ <tizen:name>NuvioProxy</tizen:name>
24
+ <tizen:description>Nuvio leanback proxy</tizen:description>
25
+ </tizen:service>
15
26
  <tizen:setting screen-orientation="landscape" context-menu="disable"
16
27
  background-support="disable" encryption="disable"
17
28
  install-location="auto" hwkey-event="enable"/>
package/css/style.css CHANGED
@@ -209,3 +209,7 @@ html, body {
209
209
  .spin { width: 70px; height: 70px; border: 7px solid rgba(255,255,255,0.2); border-top-color: #818cf8; border-radius: 50%; -webkit-animation: sp 0.9s linear infinite; animation: sp 0.9s linear infinite; }
210
210
  @-webkit-keyframes sp { to { -webkit-transform: rotate(360deg); } }
211
211
  @keyframes sp { to { transform: rotate(360deg); } }
212
+
213
+ /* YouTube leanback overlay (IPTV YouTube channels via the localhost proxy) */
214
+ #yt-leanback { position: absolute; top: 0; left: 0; width: 1920px; height: 1080px; z-index: 46; background: #000; }
215
+ #yt-leanback iframe { width: 1920px; height: 1080px; border: 0; display: block; background: #000; }
package/index.html CHANGED
@@ -4,7 +4,7 @@
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=1920, height=1080, user-scalable=no">
6
6
  <title>Nuvio</title>
7
- <link rel="stylesheet" href="css/style.css?v=1.8.12">
7
+ <link rel="stylesheet" href="css/style.css?v=1.9.0">
8
8
  </head>
9
9
  <body>
10
10
  <!-- CSS-applied sentinel (kept off-screen); app.js waits for its width before rendering -->
@@ -42,18 +42,23 @@
42
42
  <!-- Skip intro/recap/credits prompt (TheIntroDB) -->
43
43
  <div id="skip-prompt" class="hidden"></div>
44
44
 
45
+ <!-- YouTube leanback overlay (IPTV YouTube channels, via the localhost proxy) -->
46
+ <div id="yt-leanback" class="hidden"></div>
47
+
45
48
  <div id="toast" class="hidden"></div>
46
49
  <div id="spinner" class="hidden"><div class="spin"></div></div>
47
50
 
48
- <!-- Load order matters: polyfills -> util -> data -> input -> player -> views -> app -->
51
+ <!-- Load order matters: polyfills -> util -> iptv -> data -> input -> player -> ytplay -> views -> app -->
49
52
  <!-- ?v= busts the TV webview cache on every release (keep in sync with package.json) -->
50
- <script src="js/polyfills.js?v=1.8.12"></script>
51
- <script src="js/util.js?v=1.8.12"></script>
52
- <script src="js/stremio.js?v=1.8.12"></script>
53
- <script src="js/keys.js?v=1.8.12"></script>
54
- <script src="js/focus.js?v=1.8.12"></script>
55
- <script src="js/player.js?v=1.8.12"></script>
56
- <script src="js/views.js?v=1.8.12"></script>
57
- <script src="js/app.js?v=1.8.12"></script>
53
+ <script src="js/polyfills.js?v=1.9.0"></script>
54
+ <script src="js/util.js?v=1.9.0"></script>
55
+ <script src="js/iptv.js?v=1.9.0"></script>
56
+ <script src="js/stremio.js?v=1.9.0"></script>
57
+ <script src="js/keys.js?v=1.9.0"></script>
58
+ <script src="js/focus.js?v=1.9.0"></script>
59
+ <script src="js/player.js?v=1.9.0"></script>
60
+ <script src="js/ytplay.js?v=1.9.0"></script>
61
+ <script src="js/views.js?v=1.9.0"></script>
62
+ <script src="js/app.js?v=1.9.0"></script>
58
63
  </body>
59
64
  </html>
package/js/iptv.js ADDED
@@ -0,0 +1,160 @@
1
+ /* IPTV / M3U support. Global namespace: IPTV
2
+ Parses a user-supplied M3U playlist into Stremio-shaped `tv` catalogs, so the
3
+ rest of the app (home rows, search, detail, player) treats live channels exactly
4
+ like any other addon's content. Exposed to Stremio as a local synthetic addon
5
+ (see synthAddon()); Stremio.loadAddons / getCatalog / getMeta / getStreams route
6
+ `iptv:` ids here instead of over HTTP.
7
+
8
+ Direct HTTP(S) streams play through AVPlay. YouTube channels are tagged `_yt` and
9
+ handed to YTPlay (leanback proxy) at playback time. */
10
+ var IPTV = (function () {
11
+ 'use strict';
12
+
13
+ var URL_KEY = 'nuvio.iptv.url';
14
+ var CACHE_MS = 10 * 60 * 1000;
15
+ var _cache = null; // { ts, channels, groups, byId, catalogs }
16
+
17
+ function getUrl() { return U.store.get(URL_KEY, '') || ''; }
18
+ function isEnabled() { return !!getUrl(); }
19
+ function reset() { _cache = null; }
20
+ function setUrl(u) {
21
+ u = ('' + (u || '')).trim();
22
+ if (u) { U.store.set(URL_KEY, u); } else { U.store.remove(URL_KEY); }
23
+ reset();
24
+ }
25
+
26
+ function slug(s) {
27
+ var v = ('' + s).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
28
+ return v.slice(0, 40) || 'x';
29
+ }
30
+
31
+ function attr(line, re) { var m = line.match(re); return m ? m[1] : ''; }
32
+
33
+ // Returns [{ id, name, logo, group, tvgId, ua, ref, url, _yt }]
34
+ function parseM3U(text) {
35
+ var lines = ('' + text).split(/\r?\n/);
36
+ var channels = [];
37
+ var cur = null;
38
+ var seen = {};
39
+ for (var i = 0; i < lines.length; i++) {
40
+ var s = lines[i].replace(/^\s+/, '');
41
+ if (s.indexOf('#EXTINF') === 0) {
42
+ cur = {
43
+ name: (s.slice(s.lastIndexOf(',') + 1).trim()) || 'Channel',
44
+ logo: attr(s, /tvg-logo="([^"]*)"/),
45
+ group: attr(s, /group-title="([^"]*)"/) || 'Live TV',
46
+ tvgId: attr(s, /tvg-id="([^"]*)"/),
47
+ ua: attr(s, /http-user-agent="([^"]*)"/),
48
+ ref: attr(s, /http-referrer="([^"]*)"/),
49
+ url: ''
50
+ };
51
+ } else if (s.indexOf('#EXTVLCOPT:http-user-agent=') === 0) {
52
+ if (cur) { cur.ua = s.slice('#EXTVLCOPT:http-user-agent='.length).trim(); }
53
+ } else if (s.indexOf('#EXTVLCOPT:http-referrer=') === 0) {
54
+ if (cur) { cur.ref = s.slice('#EXTVLCOPT:http-referrer='.length).trim(); }
55
+ } else if (s && s.charAt(0) !== '#') {
56
+ if (cur) {
57
+ var u = s.trim();
58
+ if (/^https?:\/\//i.test(u)) {
59
+ cur.url = u;
60
+ cur._yt = /(^|\.)youtube\.com\//i.test(u) || /(^|\.)youtu\.be\//i.test(u);
61
+ var base = 'iptv:' + slug(cur.tvgId || cur.name);
62
+ var id = base, n = 2;
63
+ while (seen[id]) { id = base + '-' + n; n++; }
64
+ seen[id] = 1; cur.id = id;
65
+ channels.push(cur);
66
+ }
67
+ cur = null;
68
+ }
69
+ }
70
+ }
71
+ return channels;
72
+ }
73
+
74
+ function build(channels) {
75
+ var groups = [], gseen = {}, byId = {};
76
+ channels.forEach(function (c) {
77
+ byId[c.id] = c;
78
+ if (!gseen[c.group]) { gseen[c.group] = 1; groups.push(c.group); }
79
+ });
80
+ var catalogs = groups.map(function (g) {
81
+ return { type: 'tv', id: 'iptv_' + slug(g), name: g, extra: [{ name: 'search' }], _group: g };
82
+ });
83
+ return { ts: Date.now(), channels: channels, groups: groups, byId: byId, catalogs: catalogs };
84
+ }
85
+
86
+ function load() {
87
+ if (_cache && (Date.now() - _cache.ts) < CACHE_MS) { return Promise.resolve(_cache); }
88
+ var url = getUrl();
89
+ if (!url) { _cache = build([]); return Promise.resolve(_cache); }
90
+ return U.getText(url, 15000).then(function (text) {
91
+ _cache = build(parseM3U(text));
92
+ return _cache;
93
+ }, function () {
94
+ _cache = build([]); // keep the app usable if the playlist is unreachable
95
+ return _cache;
96
+ });
97
+ }
98
+
99
+ function toMeta(c) {
100
+ return {
101
+ id: c.id, type: 'tv', name: c.name,
102
+ poster: c.logo || '', background: c.logo || '', logo: c.logo || '',
103
+ posterShape: 'landscape', description: c.group
104
+ };
105
+ }
106
+
107
+ function catalog(catId, extra) {
108
+ return load().then(function (d) {
109
+ var cat = null;
110
+ for (var i = 0; i < d.catalogs.length; i++) {
111
+ if (d.catalogs[i].id === catId) { cat = d.catalogs[i]; break; }
112
+ }
113
+ if (!cat) { return []; }
114
+ var q = (extra && extra.search) ? ('' + extra.search).toLowerCase() : '';
115
+ var out = [];
116
+ d.channels.forEach(function (c) {
117
+ if (c.group !== cat._group) { return; }
118
+ if (q && c.name.toLowerCase().indexOf(q) === -1) { return; }
119
+ out.push(toMeta(c));
120
+ });
121
+ return out;
122
+ });
123
+ }
124
+
125
+ function meta(id) {
126
+ return load().then(function (d) { var c = d.byId[id]; return c ? toMeta(c) : null; });
127
+ }
128
+
129
+ function streams(id) {
130
+ return load().then(function (d) {
131
+ var c = d.byId[id];
132
+ if (!c) { return []; }
133
+ if (c._yt) {
134
+ return [{ url: c.url, _yt: true, ytPage: c.url, name: c.name, title: 'YouTube · ' + c.group }];
135
+ }
136
+ var st = { url: c.url, name: c.name, title: 'IPTV · ' + c.group };
137
+ if (c.ua || c.ref) { st._headers = { ua: c.ua || '', ref: c.ref || '' }; }
138
+ return [st];
139
+ });
140
+ }
141
+
142
+ // A local addon object shaped like the HTTP addons in Stremio.loadAddons().
143
+ // `__local` tells Stremio to route resource calls here instead of over HTTP.
144
+ function synthAddon() {
145
+ var d = _cache || build([]);
146
+ return {
147
+ __local: true, url: 'local:iptv', base: 'local:iptv',
148
+ manifest: {
149
+ id: 'org.nuvio.iptv', name: 'IPTV', version: '1.0.0',
150
+ types: ['tv'], resources: ['catalog', 'meta', 'stream'],
151
+ idPrefixes: ['iptv:'], catalogs: d.catalogs
152
+ }
153
+ };
154
+ }
155
+
156
+ return {
157
+ getUrl: getUrl, setUrl: setUrl, isEnabled: isEnabled, reset: reset,
158
+ load: load, catalog: catalog, meta: meta, streams: streams, synthAddon: synthAddon
159
+ };
160
+ })();
package/js/player.js CHANGED
@@ -54,6 +54,12 @@ var Player = (function () {
54
54
  var _barOpen = false;
55
55
  var _barIdx = 0;
56
56
 
57
+ // seek robustness (AVPlay can error if you seek mid-buffer)
58
+ var _buffering = false;
59
+ var _pendingSeek = null;
60
+ var _lastSeekAt = 0;
61
+ var _seekRecovering = false;
62
+
57
63
  var _active = false;
58
64
  var _onExit = null;
59
65
  var _ctx = null; // { id, type, name, poster, resumeSec }
@@ -161,6 +167,7 @@ var Player = (function () {
161
167
  _audioIdx = null;
162
168
  _menuOpen = false; _barOpen = false;
163
169
  _segments = null; _creditsSec = null; _creditsHandled = false; _segShown = {};
170
+ _buffering = false; _pendingSeek = null; _lastSeekAt = 0; _seekRecovering = false;
164
171
  hideSkip();
165
172
  subsEl.className = 'hidden'; U.clear(subsEl);
166
173
  subMenuEl.className = 'hidden';
@@ -221,12 +228,34 @@ var Player = (function () {
221
228
  } catch (e) {}
222
229
  try {
223
230
  avplay.open(url);
231
+ // Best-effort per-stream HTTP headers (some IPTV channels need a UA / Referer).
232
+ // Guarded: unsupported properties simply throw and are ignored.
233
+ try {
234
+ var h = _ctx && _ctx._headers;
235
+ if (h && h.ua) { avplay.setStreamingProperty('USER_AGENT', h.ua); }
236
+ if (h && h.ref) { avplay.setStreamingProperty('CUSTOM_MESSAGE', 'Referer:' + h.ref); }
237
+ } catch (eh) {}
224
238
  avplay.setListener({
225
- onbufferingstart: function () { U.spinner(true); },
226
- onbufferingcomplete: function () { U.spinner(false); },
239
+ onbufferingstart: function () { _buffering = true; U.spinner(true); },
240
+ onbufferingcomplete: function () {
241
+ _buffering = false; U.spinner(false);
242
+ if (_pendingSeek != null) { var pt = _pendingSeek; _pendingSeek = null; doAVSeek(pt, 0); }
243
+ },
227
244
  onstreamcompleted: function () { _cur = _dur; onEnded(); },
228
245
  oncurrentplaytime: function (ms) { _cur = ms / 1000; onProgress(); },
229
- onerror: function (err) { U.spinner(false); U.toast('Playback error: ' + err); setTimeout(exit, 1500); }
246
+ onerror: function (err) {
247
+ U.spinner(false);
248
+ // A seek can throw a transient error on some HLS streams. If we just
249
+ // sought, try to recover (resume) instead of ending playback.
250
+ if (Date.now() - _lastSeekAt < 6000 && !_seekRecovering) {
251
+ _seekRecovering = true;
252
+ try { avplay.play(); } catch (e) {}
253
+ setTimeout(function () { _seekRecovering = false; }, 4000);
254
+ return;
255
+ }
256
+ U.toast('Playback error: ' + err);
257
+ setTimeout(exit, 1500);
258
+ }
230
259
  });
231
260
  try { avplay.setStreamingProperty('ADAPTIVE_INFO', 'BITRATES=1'); } catch (e0) {}
232
261
  avplay.prepareAsync(function () {
@@ -395,17 +424,33 @@ var Player = (function () {
395
424
 
396
425
  function seekTo(sec) {
397
426
  if (_isLive) { showOSD(false); U.toast('Live — seeking unavailable', 1500); return; }
398
- var target = Math.max(0, Math.min((_dur || 1e9), sec));
427
+ // keep a small margin from the very end (seeking to EOF can error)
428
+ var maxT = _dur ? Math.max(0, _dur - 2) : 1e9;
429
+ var target = Math.max(0, Math.min(maxT, sec));
399
430
  _cur = target;
400
431
  renderOSD();
401
432
  showOSD(false);
402
433
  if (useAV) {
403
- try { avplay.seekTo(Math.floor(target * 1000)); } catch (e) {}
434
+ // Don't seek while buffering queue it until buffering completes.
435
+ if (_buffering) { _pendingSeek = target; U.spinner(true); return; }
436
+ doAVSeek(target, 0);
404
437
  } else {
405
438
  try { videoEl.currentTime = target; } catch (e2) {}
406
439
  }
407
440
  }
408
441
 
442
+ // Seek only when AVPlay is in a seekable state; retry briefly otherwise.
443
+ function doAVSeek(targetSec, attempt) {
444
+ _lastSeekAt = Date.now();
445
+ var st = '';
446
+ try { st = avplay.getState(); } catch (e) {}
447
+ if (st === 'PLAYING' || st === 'PAUSED' || st === 'READY') {
448
+ try { avplay.seekTo(Math.floor(targetSec * 1000)); } catch (e2) { /* non-fatal */ }
449
+ } else if (attempt < 4 && _active) {
450
+ setTimeout(function () { if (_active) { doAVSeek(targetSec, attempt + 1); } }, 350);
451
+ }
452
+ }
453
+
409
454
  function seekBy(delta) { seekTo(_cur + delta); }
410
455
 
411
456
  // ---------- subtitles ----------
package/js/stremio.js CHANGED
@@ -84,6 +84,8 @@ var Stremio = (function () {
84
84
  // ---- Manifest loading (cached in-memory per session) ----
85
85
  var _loaded = null;
86
86
 
87
+ var _hasIptv = (typeof IPTV !== 'undefined');
88
+
87
89
  function loadAddons() {
88
90
  if (_loaded) { return Promise.resolve(_loaded); }
89
91
  var urls = getAddonUrls();
@@ -94,14 +96,27 @@ var Stremio = (function () {
94
96
  });
95
97
  };
96
98
  });
97
- return U.settleAll(tasks, 4).then(function (results) {
99
+ // Load the IPTV playlist first (if configured) so its synthetic addon's
100
+ // catalogs reflect the parsed channel groups.
101
+ var pre = (_hasIptv && IPTV.isEnabled()) ? IPTV.load() : Promise.resolve(null);
102
+ return pre.then(function () {
103
+ return U.settleAll(tasks, 4);
104
+ }).then(function (results) {
98
105
  var addons = [];
99
106
  results.forEach(function (r) { if (r.ok && r.value.manifest) { addons.push(r.value); } });
107
+ if (_hasIptv && IPTV.isEnabled()) { addons.push(IPTV.synthAddon()); }
100
108
  _loaded = addons;
101
109
  return addons;
102
110
  });
103
111
  }
104
112
 
113
+ // Point Nuvio's IPTV playlist at a new M3U URL and force a reload.
114
+ function iptvSetUrl(url) {
115
+ if (_hasIptv) { IPTV.setUrl(url); }
116
+ _loaded = null;
117
+ }
118
+ function iptvGetUrl() { return _hasIptv ? IPTV.getUrl() : ''; }
119
+
105
120
  // ---- Resource matching ----
106
121
  function resourceEntries(addon) {
107
122
  var res = (addon.manifest.resources) || [];
@@ -174,6 +189,7 @@ var Stremio = (function () {
174
189
  }
175
190
 
176
191
  function getCatalog(cat, extra) {
192
+ if (cat.addon && cat.addon.__local) { return IPTV.catalog(cat.id, extra); }
177
193
  return U.getJSON(buildCatalogUrl(cat.addon, cat.type, cat.id, extra), 12000)
178
194
  .then(function (res) { return (res && res.metas) ? res.metas : []; });
179
195
  }
@@ -243,6 +259,12 @@ var Stremio = (function () {
243
259
 
244
260
  // ---- Meta ----
245
261
  function getMeta(type, id) {
262
+ if (/^iptv:/.test(id)) {
263
+ return IPTV.meta(id).then(function (m) {
264
+ if (!m) { throw new Error('IPTV channel not found'); }
265
+ return m;
266
+ });
267
+ }
246
268
  return loadAddons().then(function (addons) {
247
269
  var providers = addons.filter(function (a) { return supports(a, 'meta', type, id); });
248
270
  if (!providers.length) { return Promise.reject(new Error('No meta addon for ' + type)); }
@@ -263,6 +285,7 @@ var Stremio = (function () {
263
285
  // ---- Streams ----
264
286
  // Aggregates stream lists from every addon that supports the (type,id).
265
287
  function getStreams(type, id) {
288
+ if (/^iptv:/.test(id)) { return IPTV.streams(id); }
266
289
  return loadAddons().then(function (addons) {
267
290
  var providers = addons.filter(function (a) { return supports(a, 'stream', type, id); });
268
291
  var tasks = providers.map(function (a) {
@@ -518,6 +541,7 @@ var Stremio = (function () {
518
541
  getMeta: getMeta, getStreams: getStreams, getSubtitles: getSubtitles,
519
542
  getSearchCatalogs: getSearchCatalogs, search: search,
520
543
  isPlayable: isPlayable, streamLabel: streamLabel, streamName: streamName,
521
- streamSize: streamSize, streamDescription: streamDescription
544
+ streamSize: streamSize, streamDescription: streamDescription,
545
+ iptvSetUrl: iptvSetUrl, iptvGetUrl: iptvGetUrl
522
546
  };
523
547
  })();
package/js/views.js CHANGED
@@ -517,8 +517,10 @@ var Views = (function () {
517
517
 
518
518
  function startPlayback(stream, videoId, label) {
519
519
  markHomeDirty(); // progress/watched will change → refresh Continue Watching on return
520
+ // YouTube-live IPTV channels play through the leanback proxy overlay, not AVPlay.
521
+ if (stream._yt) { YTPlay.open(stream, function () { drawDetail(); }); return; }
520
522
  var ctx = { url: stream.url, type: _d.type, id: videoId, name: label,
521
- poster: _d.meta.poster, streams: playableSources() };
523
+ poster: _d.meta.poster, streams: playableSources(), _headers: stream._headers };
522
524
  if (_d.type === 'series' && _d.meta.videos) {
523
525
  var ord = orderedEpisodes(_d.meta);
524
526
  var idx = ord.findIndex(function (v) { return v.id === videoId; });
@@ -756,6 +758,7 @@ var Views = (function () {
756
758
  var wrap = U.el('div', 'settings-wrap');
757
759
  s.appendChild(wrap);
758
760
  if (_settingsSection === 'addons') { settingsAddons(wrap); }
761
+ else if (_settingsSection === 'iptv') { settingsIptv(wrap); }
759
762
  else if (_settingsSection === 'integrations') { settingsIntegrations(wrap); }
760
763
  else if (_settingsSection === 'subtitles') { settingsSubtitles(wrap); }
761
764
  else if (_settingsSection === 'feeds') { settingsFeeds(wrap); }
@@ -780,6 +783,7 @@ var Views = (function () {
780
783
  function settingsMenu(wrap) {
781
784
  wrap.appendChild(U.el('h2', null, 'Settings'));
782
785
  wrap.appendChild(sectionRow('Addons', 'Catalog, stream & subtitle addons', 'addons'));
786
+ wrap.appendChild(sectionRow('Live TV (IPTV)', 'Load channels from an M3U playlist URL', 'iptv'));
783
787
  wrap.appendChild(sectionRow('Integrations', 'API keys (OMDb, TMDB, MDBList)', 'integrations'));
784
788
  wrap.appendChild(sectionRow('Subtitle Options', 'Default language, size, style', 'subtitles'));
785
789
  wrap.appendChild(sectionRow('Home Feeds', 'Order & show/hide the home rows', 'feeds'));
@@ -843,6 +847,57 @@ var Views = (function () {
843
847
  });
844
848
  }
845
849
 
850
+ function settingsIptv(wrap) {
851
+ wrap.appendChild(U.el('h2', null, 'Live TV (IPTV)'));
852
+ var url = Stremio.iptvGetUrl ? Stremio.iptvGetUrl() : '';
853
+
854
+ var set = U.el('div', 'addon-item focusable');
855
+ set.appendChild(U.el('div', 'a-name', url ? 'M3U playlist' : '+ Set M3U playlist URL'));
856
+ set.appendChild(U.el('div', 'a-url', url || 'Not set — press OK to enter an M3U URL'));
857
+ set.__onselect = function () {
858
+ App.openEntry({
859
+ title: 'IPTV playlist URL',
860
+ hint: 'Type an M3U/M3U8 playlist URL, then Confirm. RETURN cancels — nothing is saved unless you confirm.',
861
+ value: url || 'https://', placeholder: 'https://example.com/playlist.m3u',
862
+ validate: function (v) { return (!v || v === 'https://' || v.indexOf('://') === -1 || v.length < 12) ? 'Enter a full M3U URL' : null; },
863
+ onConfirm: function (v) { Stremio.iptvSetUrl(v); clearCatalogCache(); U.toast('Playlist saved'); App.back(); renderSettings(); }
864
+ });
865
+ };
866
+ wrap.appendChild(set);
867
+
868
+ if (url) {
869
+ var reload = U.el('div', 'addon-item focusable');
870
+ reload.appendChild(U.el('div', 'a-name', '↻ Reload playlist'));
871
+ reload.appendChild(U.el('div', 'a-url', 'Re-fetch channels from the M3U URL now'));
872
+ reload.__onselect = function () {
873
+ Stremio.iptvSetUrl(url); // re-sets same URL, clears cache
874
+ clearCatalogCache();
875
+ U.toast('Reloading channels…');
876
+ setTimeout(renderSettings, 300);
877
+ };
878
+ wrap.appendChild(reload);
879
+
880
+ var rm = U.el('div', 'addon-item focusable reset-item');
881
+ rm.appendChild(U.el('div', 'a-name', '✕ Remove playlist'));
882
+ rm.appendChild(U.el('div', 'a-url', 'Clears the IPTV channels from Nuvio'));
883
+ rm.__onselect = function () {
884
+ confirm('Remove the IPTV playlist?', 'Remove', function () {
885
+ Stremio.iptvSetUrl('');
886
+ clearCatalogCache();
887
+ U.toast('Playlist removed');
888
+ setTimeout(renderSettings, 300);
889
+ });
890
+ };
891
+ wrap.appendChild(rm);
892
+ }
893
+
894
+ wrap.appendChild(U.el('div', 'hint',
895
+ 'Channels are grouped by the playlist\'s group-title into Live TV rows on Home. ' +
896
+ 'Direct HLS streams play via AVPlay; YouTube channels play via the built-in leanback proxy.'));
897
+ wrap.appendChild(backRow());
898
+ Focus.setScope(scr('settings'));
899
+ }
900
+
846
901
  function maskKey(v) { v = '' + v; return v.length <= 4 ? '••••' : ('••••' + v.slice(-4)); }
847
902
 
848
903
  function keyRow(title, key, placeholder, validator) {
package/js/ytplay.js ADDED
@@ -0,0 +1,115 @@
1
+ /* YouTube leanback playback. Global namespace: YTPlay
2
+ YouTube-live IPTV channels can't be extracted to a direct URL on-device and
3
+ youtube.com/tv can't be framed (X-Frame-Options). So we launch a bundled
4
+ localhost proxy (service/index.js) that serves youtube.com/tv with those
5
+ headers stripped, and load it in a fullscreen overlay iframe — the same
6
+ leanback approach TizenTube uses. See service/index.js for the proxy. */
7
+ var YTPlay = (function () {
8
+ 'use strict';
9
+
10
+ var PROXY = 'http://localhost:8099';
11
+ var _open = false;
12
+ var _onExit = null;
13
+ var _idCache = {}; // ytPage -> { id, ts }
14
+ var ID_TTL = 5 * 60 * 1000; // live video ids rotate; keep short
15
+
16
+ function overlay() { return U.byId('yt-leanback'); }
17
+
18
+ // Launch the proxy service (idempotent) and resolve once it answers /health.
19
+ function ensureService() {
20
+ return new Promise(function (resolve, reject) {
21
+ if (!(window.tizen && tizen.application)) { reject(new Error('no-service')); return; }
22
+ function poll(left) {
23
+ U.getText(PROXY + '/health', 2500).then(function () { resolve(); }, function () {
24
+ if (left <= 0) { reject(new Error('proxy-timeout')); return; }
25
+ setTimeout(function () { poll(left - 1); }, 500);
26
+ });
27
+ }
28
+ try {
29
+ var pkgId = tizen.application.getCurrentApplication().appInfo.packageId;
30
+ tizen.application.launchAppControl(
31
+ new tizen.ApplicationControl('http://tizen.org/appcontrol/operation/service'),
32
+ pkgId + '.NuvioProxy',
33
+ function () { poll(40); }, // launched → wait for it to listen (~20s)
34
+ function () { poll(40); } // may already be running
35
+ );
36
+ } catch (e) { poll(40); }
37
+ });
38
+ }
39
+
40
+ // A youtube.com/<channel>/live (or /watch?v=) URL -> the current live video id.
41
+ function resolveVideoId(page) {
42
+ var direct = ('' + page).match(/[?&]v=([A-Za-z0-9_-]{11})/);
43
+ if (direct) { return Promise.resolve(direct[1]); }
44
+ var hit = _idCache[page];
45
+ if (hit && (Date.now() - hit.ts) < ID_TTL) { return Promise.resolve(hit.id); }
46
+ return U.getText(page, 12000).then(function (html) {
47
+ var m = html.match(/"videoId":"([A-Za-z0-9_-]{11})"/);
48
+ var id = m ? m[1] : '';
49
+ if (id) { _idCache[page] = { id: id, ts: Date.now() }; }
50
+ return id;
51
+ }, function () { return ''; });
52
+ }
53
+
54
+ function onHwKey(e) {
55
+ if (e && e.keyName === 'back') { if (e.preventDefault) { e.preventDefault(); } close(); }
56
+ }
57
+ function onKeyDown(e) {
58
+ if (e && (e.keyCode === 10009 || e.keyCode === 461)) {
59
+ if (e.preventDefault) { e.preventDefault(); }
60
+ if (e.stopPropagation) { e.stopPropagation(); }
61
+ close();
62
+ }
63
+ }
64
+
65
+ function show(id) {
66
+ var o = overlay();
67
+ if (!o) { return; }
68
+ U.clear(o);
69
+ var iframe = document.createElement('iframe');
70
+ iframe.setAttribute('allow', 'autoplay; encrypted-media');
71
+ iframe.src = PROXY + '/tv#/watch?v=' + encodeURIComponent(id);
72
+ iframe.onload = function () { try { iframe.focus(); } catch (e) {} };
73
+ o.appendChild(iframe);
74
+ o.className = '';
75
+ U.byId('app').className = 'hidden';
76
+ _open = true;
77
+ // Hardware Back is delivered app-wide (tizenhwkey) even when the iframe has
78
+ // focus; the keydown capture covers the moment before the iframe grabs focus.
79
+ document.addEventListener('tizenhwkey', onHwKey, true);
80
+ document.addEventListener('keydown', onKeyDown, true);
81
+ }
82
+
83
+ function close() {
84
+ if (!_open) { return; }
85
+ _open = false;
86
+ document.removeEventListener('tizenhwkey', onHwKey, true);
87
+ document.removeEventListener('keydown', onKeyDown, true);
88
+ var o = overlay();
89
+ if (o) { o.className = 'hidden'; U.clear(o); }
90
+ U.byId('app').className = '';
91
+ var cb = _onExit; _onExit = null;
92
+ if (cb) { try { cb(); } catch (e) {} } // redraw detail -> restores focus scope
93
+ }
94
+
95
+ function open(stream, onExit) {
96
+ _onExit = onExit || null;
97
+ var page = stream.ytPage || stream.url;
98
+ U.spinner(true);
99
+ ensureService().then(function () {
100
+ return resolveVideoId(page);
101
+ }).then(function (id) {
102
+ U.spinner(false);
103
+ if (!id) { U.toast('Could not find the live stream for this channel', 3500); return; }
104
+ show(id);
105
+ }, function (err) {
106
+ U.spinner(false);
107
+ var msg = (err && err.message === 'no-service')
108
+ ? 'YouTube channels need the on-TV proxy (not available in this browser).'
109
+ : 'Could not start the YouTube proxy service.';
110
+ U.toast(msg, 4500);
111
+ });
112
+ }
113
+
114
+ return { open: open, close: close, isOpen: function () { return _open; } };
115
+ })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nuvio-tizen",
3
- "version": "1.8.12",
3
+ "version": "1.9.0",
4
4
  "description": "Nuvio-style Stremio-addon streaming client for Tizen 3.0+ Samsung TVs (TizenBrew app module).",
5
5
  "packageType": "app",
6
6
  "appName": "Nuvio",
@@ -9,6 +9,7 @@
9
9
  "index.html",
10
10
  "css",
11
11
  "js",
12
+ "service",
12
13
  "icon.png",
13
14
  "config.xml",
14
15
  "README.md"
@@ -0,0 +1,225 @@
1
+ "use strict";
2
+ /*
3
+ * Nuvio leanback proxy — Tizen <tizen:service> (Node ~4.x engine).
4
+ *
5
+ * Lets Nuvio play YouTube-live IPTV channels through YouTube's own leanback TV
6
+ * app (youtube.com/tv) inside an in-app overlay. youtube.com/tv sends
7
+ * X-Frame-Options: SAMEORIGIN and CSP, so it can't be framed directly; this
8
+ * localhost proxy strips those, rewrites media/asset URLs through /cors-bypass/,
9
+ * and injects TizenTube's ad-block userscript.
10
+ *
11
+ * Adapted from TizenTube (@krx3d/tizentube2, MIT — https://github.com/KrX3D/TizenTube):
12
+ * the /tv + /cors-bypass proxy design, the URL-rewrite rules, and the userscript
13
+ * injection are ported from its standalone/service/index.js. Reduced to Node core
14
+ * modules only (no express/node-fetch) so it needs no bundling, and additionally
15
+ * strips X-Frame-Options so Nuvio can frame it in an overlay.
16
+ *
17
+ * ES5 only — the Tizen service engine is an old Node (no let/const/arrow/templates).
18
+ */
19
+
20
+ var http = require('http');
21
+ var https = require('https');
22
+ var zlib = require('zlib');
23
+ var urllib = require('url');
24
+
25
+ var PORT = 8099;
26
+ var USERSCRIPT_CDN = 'https://cdn.jsdelivr.net/npm/@krx3d/tizentube2/dist/userScript.js';
27
+ var USERSCRIPT_FALLBACK = 'https://unpkg.com/@krx3d/tizentube2/dist/userScript.js';
28
+ var ALLOWED = ['googlevideo.com', 'youtube.com', 'gstatic.com', 'google.com',
29
+ 'googleapis.com', 'googleusercontent.com', 'ggpht.com'];
30
+
31
+ function hostAllowed(h) {
32
+ if (!h) { return false; }
33
+ for (var i = 0; i < ALLOWED.length; i++) {
34
+ var a = ALLOWED[i];
35
+ if (h === a || h.slice(-(a.length + 1)) === '.' + a) { return true; }
36
+ }
37
+ return false;
38
+ }
39
+
40
+ function proxyPrefix() { return 'http://localhost:' + PORT + '/cors-bypass/'; }
41
+
42
+ function rewriteText(text, isTvHtml) {
43
+ var p = proxyPrefix();
44
+ if (isTvHtml) {
45
+ var inject = '<script>window.__tizenTubeStandaloneVersion="nuvio";</script>' +
46
+ '<script src="' + USERSCRIPT_CDN + '?ver=' + Date.now() +
47
+ '" onerror="this.onerror=null;this.src=\'' + USERSCRIPT_FALLBACK + '\'"></script>';
48
+ if (/<body[^>]*>/i.test(text)) {
49
+ text = text.replace(/<body[^>]*>/i, function (b) { return b + inject; });
50
+ } else {
51
+ text = inject + text;
52
+ }
53
+ }
54
+ text = text.replace(/https:\/\/([a-zA-Z0-9-.]+)\.googlevideo\.com/g, p + 'https://$1.googlevideo.com');
55
+ text = text.replace(/https:\\\/\\\/([a-zA-Z0-9-.]+)\.googlevideo\.com/g,
56
+ 'http:\\/\\/localhost:' + PORT + '\\/cors-bypass\\/https:\\/\\/$1.googlevideo.com');
57
+ text = text.replace(/"\/\/([a-zA-Z0-9-.]+)\.googlevideo\.com/g, '"' + p + 'https://$1.googlevideo.com');
58
+ text = text.replace(/https:\/\/www\.gstatic\.com/g, p + 'https://www.gstatic.com');
59
+ text = text.replace(/http:\/\/www\.gstatic\.com/g, p + 'https://www.gstatic.com');
60
+ text = text.replace(/"\/\/www\.gstatic\.com/g, '"' + p + 'https://www.gstatic.com');
61
+ text = text.replace(/\(\/\/www\.gstatic\.com/g, '(' + p + 'https://www.gstatic.com');
62
+ text = text.replace(/https:\/\/yt3\.ggpht\.com/g, p + 'https://yt3.ggpht.com');
63
+ text = text.replace(/https:\/\/clients1\.google\.com/g, p + 'https://clients1.google.com');
64
+ text = text.replace(/http:\/\/clients1\.google\.com/g, p + 'https://clients1.google.com');
65
+ text = text.replace(/"\/\/clients1\.google\.com/g, '"' + p + 'https://clients1.google.com');
66
+ text = text.replace('Set(["www.youtube.com","accounts.google.com"]);',
67
+ 'Set(["www.youtube.com", "accounts.google.com", "localhost"]);');
68
+ text = text.replace(/:document\.location\.toString\(\)/g,
69
+ ':document.location.toString().replace("http://localhost:' + PORT + '", "https://www.youtube.com")');
70
+ text = text.replace(/euri:[^,]+,/g,
71
+ 'euri:document.location.toString().replace("http://localhost:' + PORT + '", "https://www.youtube.com"),');
72
+ text = text.replace(/https:\/\/s\.youtube\.com/g, p + 'https://s.youtube.com');
73
+ text = text.replace(/redirector\.googlevideo\.com/g, p + 'https://redirector.googlevideo.com');
74
+ text = text.replace(/this.scheme="https"/, 'this.scheme="http"');
75
+ text = text.replace(/https:\/\/jnn-pa\.googleapis\.com/g, p + 'https://jnn-pa.googleapis.com');
76
+ text = text.replace(/https:\/\/yt3\.googleusercontent\.com/g, p + 'https://yt3.googleusercontent.com');
77
+ text = text.replace(/"\/\/yt3\.googleusercontent\.com/g, '"' + p + 'https://yt3.googleusercontent.com');
78
+ text = text.replace(/=window\.location\.href;/,
79
+ '=window.location.href.replace("http://localhost:' + PORT + '", "https://www.youtube.com");');
80
+ text = text.replace(/=document\.location\.href/,
81
+ '=document.location.href.replace("http://localhost:' + PORT + '", "https://www.youtube.com")');
82
+ return text;
83
+ }
84
+
85
+ function rewriteCookies(arr) {
86
+ if (!arr) { return arr; }
87
+ if (!(arr instanceof Array)) { arr = [arr]; }
88
+ var out = [];
89
+ for (var i = 0; i < arr.length; i++) {
90
+ out.push(String(arr[i])
91
+ .replace(/^__Secure-/i, '__LocalSecure-')
92
+ .replace(/^__Host-/i, '__LocalHost-')
93
+ .replace(/Domain=[^;]+/i, 'Domain=localhost')
94
+ .replace(/;\s*Secure/i, '')
95
+ .replace(/;\s*SameSite=None/i, ''));
96
+ }
97
+ return out;
98
+ }
99
+
100
+ function isTextType(ct) {
101
+ ct = ct || '';
102
+ return ct.indexOf('text/html') !== -1 || ct.indexOf('application/json') !== -1 ||
103
+ ct.indexOf('javascript') !== -1 || ct.indexOf('text/css') !== -1;
104
+ }
105
+
106
+ function decompress(buf, enc, cb) {
107
+ enc = (enc || '').toLowerCase();
108
+ if (enc === 'gzip') { zlib.gunzip(buf, cb); return; }
109
+ if (enc === 'deflate') {
110
+ zlib.inflate(buf, function (e, r) { if (e) { zlib.inflateRaw(buf, cb); } else { cb(null, r); } });
111
+ return;
112
+ }
113
+ cb(null, buf);
114
+ }
115
+
116
+ var SKIP_HEADERS = ['content-encoding', 'content-length', 'transfer-encoding',
117
+ 'content-security-policy', 'x-frame-options', 'alt-svc'];
118
+
119
+ function handle(req, res) {
120
+ res.setHeader('Access-Control-Allow-Origin', '*');
121
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, PUT, PATCH, DELETE');
122
+ res.setHeader('Access-Control-Allow-Headers', '*');
123
+ if (req.method === 'OPTIONS') { res.writeHead(200); res.end(); return; }
124
+
125
+ var pathOnly = req.url.split('?')[0];
126
+ if (pathOnly === '/health') { res.writeHead(200, { 'Content-Type': 'text/plain' }); res.end('ok'); return; }
127
+
128
+ var isCors = req.url.indexOf('/cors-bypass/') === 0;
129
+ var target;
130
+ if (isCors) {
131
+ var raw = req.url.substring('/cors-bypass/'.length);
132
+ target = raw.indexOf('http') === 0 ? raw : ('https://' + raw);
133
+ } else {
134
+ target = 'https://www.youtube.com' + req.url;
135
+ }
136
+
137
+ var pu;
138
+ try { pu = urllib.parse(target); } catch (e) { res.writeHead(403); res.end('bad url'); return; }
139
+ if (pu.protocol !== 'https:' && pu.protocol !== 'http:') { res.writeHead(403); res.end('bad protocol'); return; }
140
+ if (!hostAllowed(pu.hostname)) { res.writeHead(403); res.end('host not allowed'); return; }
141
+
142
+ var isTvHtml = (!isCors && pathOnly === '/tv');
143
+
144
+ var headers = {};
145
+ for (var k in req.headers) {
146
+ if (!Object.prototype.hasOwnProperty.call(req.headers, k)) { continue; }
147
+ if (k === 'cookie') {
148
+ headers[k] = String(req.headers[k])
149
+ .replace(/__LocalSecure-/g, '__Secure-')
150
+ .replace(/__LocalHost-/g, '__Host-');
151
+ } else if (k !== 'host' && k !== 'accept-encoding') {
152
+ headers[k] = req.headers[k];
153
+ }
154
+ }
155
+ headers.host = pu.host;
156
+ headers.origin = 'https://www.youtube.com';
157
+ if (headers.referer) { headers.referer = 'https://www.youtube.com/tv'; }
158
+ headers['accept-encoding'] = 'gzip, deflate';
159
+
160
+ var mod = pu.protocol === 'http:' ? http : https;
161
+ var options = {
162
+ hostname: pu.hostname,
163
+ port: pu.port || (pu.protocol === 'http:' ? 80 : 443),
164
+ path: pu.path,
165
+ method: req.method,
166
+ headers: headers,
167
+ maxHeaderSize: 5 * 1024 * 1024 // ignored on old Node; helps where supported (YT sends big headers)
168
+ };
169
+
170
+ var preq = mod.request(options, function (pres) {
171
+ var outHeaders = {};
172
+ for (var hk in pres.headers) {
173
+ if (!Object.prototype.hasOwnProperty.call(pres.headers, hk)) { continue; }
174
+ var lk = hk.toLowerCase();
175
+ if (SKIP_HEADERS.indexOf(lk) !== -1) { continue; }
176
+ if (lk === 'set-cookie') { outHeaders[hk] = rewriteCookies(pres.headers[hk]); continue; }
177
+ if (lk === 'location') {
178
+ outHeaders[hk] = String(pres.headers[hk])
179
+ .replace(/https?:\/\/(www\.)?youtube\.com/gi, 'http://localhost:' + PORT);
180
+ continue;
181
+ }
182
+ outHeaders[hk] = pres.headers[hk];
183
+ }
184
+ outHeaders['Access-Control-Allow-Origin'] = '*';
185
+
186
+ var enc = pres.headers['content-encoding'];
187
+ var ct = pres.headers['content-type'] || '';
188
+
189
+ if (isTextType(ct)) {
190
+ var chunks = [];
191
+ pres.on('data', function (c) { chunks.push(c); });
192
+ pres.on('end', function () {
193
+ decompress(Buffer.concat(chunks), enc, function (err, buf) {
194
+ if (err) { res.writeHead(502); res.end('decompress error'); return; }
195
+ var text = rewriteText(buf.toString('utf8'), isTvHtml);
196
+ // new Buffer(): Buffer.from() doesn't exist on the TV's old Node (< 4.5).
197
+ var body = new Buffer(text, 'utf8');
198
+ outHeaders['Content-Length'] = body.length;
199
+ res.writeHead(pres.statusCode, outHeaders);
200
+ res.end(body);
201
+ });
202
+ });
203
+ pres.on('error', function () { if (!res.headersSent) { res.writeHead(502); } res.end(); });
204
+ } else {
205
+ res.writeHead(pres.statusCode, outHeaders);
206
+ var e2 = (enc || '').toLowerCase();
207
+ if (e2 === 'gzip') { pres.pipe(zlib.createGunzip()).pipe(res); }
208
+ else if (e2 === 'deflate') { pres.pipe(zlib.createInflate()).pipe(res); }
209
+ else { pres.pipe(res); }
210
+ }
211
+ });
212
+
213
+ preq.on('error', function (e) {
214
+ if (!res.headersSent) { res.writeHead(502); }
215
+ try { res.end('proxy error'); } catch (e3) {}
216
+ });
217
+
218
+ req.pipe(preq);
219
+ }
220
+
221
+ var server = http.createServer(handle);
222
+ server.on('error', function (err) { try { console.error('proxy listen error', err); } catch (e) {} });
223
+ server.listen(PORT, '127.0.0.1', function () {
224
+ try { console.log('Nuvio leanback proxy on 127.0.0.1:' + PORT); } catch (e) {}
225
+ });