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 +24 -0
- package/config.xml +14 -3
- package/css/style.css +4 -0
- package/index.html +15 -10
- package/js/iptv.js +160 -0
- package/js/player.js +50 -5
- package/js/stremio.js +26 -2
- package/js/views.js +56 -1
- package/js/ytplay.js +115 -0
- package/package.json +2 -1
- package/service/index.js +225 -0
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.
|
|
4
|
-
<tizen:application id="Nuvio00001.Nuvio" package="Nuvio00001" required_version="
|
|
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
|
-
|
|
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.
|
|
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.
|
|
51
|
-
<script src="js/util.js?v=1.
|
|
52
|
-
<script src="js/
|
|
53
|
-
<script src="js/
|
|
54
|
-
<script src="js/
|
|
55
|
-
<script src="js/
|
|
56
|
-
<script src="js/
|
|
57
|
-
<script src="js/
|
|
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 () {
|
|
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) {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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"
|
package/service/index.js
ADDED
|
@@ -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
|
+
});
|