Compare commits
219 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 80d2c4bfb3 | |||
| 4e116d864b | |||
| 7006513aaf | |||
| c8ca09011f | |||
| c042ff37b1 | |||
| ac6c82e3fc | |||
| e83b173755 | |||
| 89dbb96c92 | |||
| ea4a0a4577 | |||
| 6f966f1095 | |||
| 47d582cb8c | |||
| 13badd249e | |||
| cddf02b114 | |||
| 3be14ef2fa | |||
| 3272b19177 | |||
| 8d5e94ccca | |||
| 7d5dbd94d6 | |||
| 056555c7b4 | |||
| 059cfecdcc | |||
| 237d11cf00 | |||
| 877c6f8df9 | |||
| 358854ad7a | |||
| 4b5d6c1b78 | |||
| dd7d6bbf1c | |||
| 4061bfa2a0 | |||
| 995fe12396 | |||
| e08bcfcea7 | |||
| c7fb245d6b | |||
| 100a26fe4e | |||
| 475c50b743 | |||
| 53c577a932 | |||
| 4a8be395ca | |||
| f9fefa3947 | |||
| b890d30253 | |||
| bf111e1fca | |||
| 052178e395 | |||
| d6eda66360 | |||
| dc029f75e5 | |||
| d54428472c | |||
| 51a61aaf5a | |||
| 5752f529cb | |||
| 82b93602c1 | |||
| 8137f49c4c | |||
| 0293e9d44b | |||
| fb2a50543a | |||
| c4cc8ef63c | |||
| 8304e7abe1 | |||
| 95591a0c84 | |||
| 26ccafa3ec | |||
| 453f93599b | |||
| d85ed753ab | |||
| 81d5f6e61e | |||
| 0c797dad7a | |||
| 39fcb36e53 | |||
| c1f8077438 | |||
| c01c33eed3 | |||
| 9919975663 | |||
| de7d28d3bc | |||
| 2c34359b2c | |||
| efcf14a545 | |||
| 77eb7bff5f | |||
| f0c1393ce0 | |||
| 2a03cad213 | |||
| c1a1ac4faa | |||
| 7c9c80c2f1 | |||
| b467471590 | |||
| 196b9520b7 | |||
| 450795818e | |||
| aa0fb7b956 | |||
| 0d16182cdf | |||
| 12a24cef09 | |||
| 60010cc734 | |||
| 0a013ec05f | |||
| 616ec37a93 | |||
| 6834849cad | |||
| 17923d8996 | |||
| b81ce4856e | |||
| c951a5998e | |||
| f2fde1ea54 | |||
| 838b540922 | |||
| c589b00e57 | |||
| 04fcabda84 | |||
| a86f9ca878 | |||
| 098dc3a8ce | |||
| 5f24d7e264 | |||
| d96bb7c776 | |||
| aeee45869b | |||
| 8d36a2c07e | |||
| 346a0519e9 | |||
| 53a54c760c | |||
| e8e6ba27d7 | |||
| 67d9cb26d2 | |||
| ffd995c1c5 | |||
| 7184a1ba56 | |||
| 05996985f1 | |||
| 3e834d4f41 | |||
| 28f16980e8 | |||
| 955d68e899 | |||
| 5bb0629de2 | |||
| f525a6ee67 | |||
| 3e377da67c | |||
| 2d20678e05 | |||
| 6557b9061f | |||
| 7f9485b663 | |||
| b119b37b87 | |||
| 813cd0cbe6 | |||
| a00082b0ef | |||
| ca87a5f143 | |||
| 9256883aa6 | |||
| bd9106be49 | |||
| dd95c6f6ed | |||
| 8d37f22f53 | |||
| 374a87b2fd | |||
| 1193e07017 | |||
| 8805ddbc13 | |||
| ec814be3dd | |||
| d1871f4077 | |||
| fb176412f0 | |||
| b7bc03a960 | |||
| 55c9a796b4 | |||
| 1be84d3ade | |||
| c48f2e070e | |||
| 02ff6f3f80 | |||
| 024b847d05 | |||
| d5ebd270dc | |||
| 0a6ebccce2 | |||
| bed168c646 | |||
| 096420596b | |||
| e1a197f2be | |||
| fc2f20d921 | |||
| 9fb8df7255 | |||
| ec9a6bcc23 | |||
| 46c68813ec | |||
| 51c2fb915c | |||
| 218757c6c3 | |||
| 54cedd197b | |||
| 2531f1786f | |||
| ea3aff2f04 | |||
| 82a0743262 | |||
| ed18b8662c | |||
| 2ab9b34f73 | |||
| 79da89ca40 | |||
| d1bfd1b9e7 | |||
| 404364b904 | |||
| a90ac32a3b | |||
| bec1b74208 | |||
| 7592702137 | |||
| 4683582dd0 | |||
| 1f4e38b37e | |||
| 6d55a81d3d | |||
| e70232055f | |||
| 20e4101649 | |||
| f3dd545eb4 | |||
| 71d59947b5 | |||
| c037ef6862 | |||
| 6daa3071be | |||
| 264a184f52 | |||
| bfb984be6c | |||
| b5c8581318 | |||
| d63c9c37d7 | |||
| 3b0d311756 | |||
| 3fffdddf4b | |||
| 3b14a17bfb | |||
| f8d625e9fe | |||
| bca9aa8c0a | |||
| 0a94904335 | |||
| 0513016f6c | |||
| 9aabfc9b43 | |||
| 413f5e2507 | |||
| 59bc6d4a46 | |||
| dc89e8018f | |||
| d57d7a5fc9 | |||
| 83ca622efa | |||
| 32e0434785 | |||
| 61fe03f950 | |||
| e4ae0e9b69 | |||
| 54a4b84d6e | |||
| 4931c08acd | |||
| a216eb32bb | |||
| 9ef9c847fc | |||
| 29a7996ecd | |||
| 44365c4dad | |||
| e8bfb56c8b | |||
| b504faf909 | |||
| 3b460591e0 | |||
| 3de1971383 | |||
| 15098ad6ed | |||
| 2ebe52685f | |||
| dc15e1f396 | |||
| 7e5a4174d1 | |||
| 4e398bbe38 | |||
| 3f4fd49023 | |||
| 7260f988ca | |||
| 89769dccb0 | |||
| 5da26c8045 | |||
| 58ee98adc2 | |||
| 1f81323f16 | |||
| 064d550cc8 | |||
| d251488f5f | |||
| 57b0f7ba81 | |||
| ebb3491005 | |||
| a7e0db7a4f | |||
| 1a80b4c036 | |||
| ec05112f8f | |||
| c25da2e011 | |||
| 4ab2128f74 | |||
| 79d84aa64b | |||
| 6a1d5b44e9 | |||
| f76e41c398 | |||
| 762131f939 | |||
| 2028ecab08 | |||
| bb5dc89311 | |||
| 52a7cd53b4 | |||
| e73ed9d8b1 | |||
| fda2cc580e | |||
| 52bb407fcc | |||
| 275c713296 | |||
| 235b2099de | |||
| 4f9ec55874 |
@@ -1,10 +0,0 @@
|
|||||||
# Copiar a .env y ajustar
|
|
||||||
PORT=3000
|
|
||||||
JWT_SECRET=cambia_esto_en_produccion
|
|
||||||
|
|
||||||
# Backend Xtream (Dispatcharr en tu red)
|
|
||||||
# Ejemplo: http://10.10.10.101:9191
|
|
||||||
# En la UI de KiraTV añades una fuente Xtream con la misma URL y usuario/contraseña de Dispatcharr.
|
|
||||||
|
|
||||||
# Opcional: si usas balanceador dedicado solo para /api/transcode, la app debe generar URLs absolutas
|
|
||||||
# con ese host (requiere cambios en front); por defecto mismo origen vía reverse proxy en 10.10.10.115
|
|
||||||
@@ -46,7 +46,7 @@ nodecast-tv is a modern, web-based IPTV player featuring Live TV, EPG, Movies (V
|
|||||||
|
|
||||||
1. Clone the repository:
|
1. Clone the repository:
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/technomancer702/nodecast-tv.git
|
git clone https://github.com/yourusername/nodecast-tv.git
|
||||||
cd nodecast-tv
|
cd nodecast-tv
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -114,7 +114,7 @@ Ensure you have the [NVIDIA Container Toolkit](https://docs.nvidia.com/datacente
|
|||||||
devices:
|
devices:
|
||||||
- driver: nvidia
|
- driver: nvidia
|
||||||
count: 1
|
count: 1
|
||||||
capabilities: [gpu, utility, video, compute]
|
capabilities: [gpu]
|
||||||
```
|
```
|
||||||
|
|
||||||
**Verify:**
|
**Verify:**
|
||||||
|
|||||||
-869
@@ -1,869 +0,0 @@
|
|||||||
diff --git a/public/index.html b/public/index.html
|
|
||||||
index dd74211..9205ef7 100644
|
|
||||||
--- a/public/index.html
|
|
||||||
+++ b/public/index.html
|
|
||||||
@@ -9,7 +9,7 @@
|
|
||||||
<meta name="mobile-web-app-capable" content="yes">
|
|
||||||
<title>nodecast-tv | IPTV Player</title>
|
|
||||||
<meta name="description" content="Stream live TV, movies, and series with EPG support">
|
|
||||||
- <link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
|
||||||
+ <link rel="icon" type="image/svg+xml" href="favicon.svg">
|
|
||||||
<link rel="stylesheet" href="css/main.css">
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
@@ -1095,20 +1095,20 @@
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- JavaScript -->
|
|
||||||
- <script src="/js/icons.js"></script>
|
|
||||||
- <script src="/js/api.js?v=2"></script>
|
|
||||||
- <script src="/js/components/VideoPlayer.js?v=2"></script>
|
|
||||||
- <script src="/js/components/ChannelList.js?v=3"></script>
|
|
||||||
- <script src="/js/components/SourceManager.js?v=2"></script>
|
|
||||||
- <script src="/js/components/EpgGuide.js?v=2"></script>
|
|
||||||
- <script src="/js/pages/HomePage.js?v=2"></script>
|
|
||||||
- <script src="/js/pages/LivePage.js?v=2"></script>
|
|
||||||
- <script src="/js/pages/Guide.js?v=2"></script>
|
|
||||||
- <script src="/js/pages/MoviesPage.js?v=4"></script>
|
|
||||||
- <script src="/js/pages/SeriesPage.js?v=1"></script>
|
|
||||||
- <script src="/js/pages/Settings.js?v=2"></script>
|
|
||||||
- <script src="/js/pages/WatchPage.js?v=1"></script>
|
|
||||||
- <script src="/js/app.js?v=4"></script>
|
|
||||||
+ <script src="js/icons.js"></script>
|
|
||||||
+ <script src="js/api.js?v=2"></script>
|
|
||||||
+ <script src="js/components/VideoPlayer.js?v=2"></script>
|
|
||||||
+ <script src="js/components/ChannelList.js?v=3"></script>
|
|
||||||
+ <script src="js/components/SourceManager.js?v=2"></script>
|
|
||||||
+ <script src="js/components/EpgGuide.js?v=2"></script>
|
|
||||||
+ <script src="js/pages/HomePage.js?v=2"></script>
|
|
||||||
+ <script src="js/pages/LivePage.js?v=2"></script>
|
|
||||||
+ <script src="js/pages/Guide.js?v=2"></script>
|
|
||||||
+ <script src="js/pages/MoviesPage.js?v=4"></script>
|
|
||||||
+ <script src="js/pages/SeriesPage.js?v=1"></script>
|
|
||||||
+ <script src="js/pages/Settings.js?v=2"></script>
|
|
||||||
+ <script src="js/pages/WatchPage.js?v=1"></script>
|
|
||||||
+ <script src="js/app.js?v=4"></script>
|
|
||||||
<script>
|
|
||||||
// Check for SSO token in URL
|
|
||||||
(function () {
|
|
||||||
@@ -1125,4 +1125,4 @@
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
-</html>
|
|
||||||
\ No newline at end of file
|
|
||||||
+</html>
|
|
||||||
diff --git a/public/js/api.js b/public/js/api.js
|
|
||||||
index 77b1306..ed4b354 100644
|
|
||||||
--- a/public/js/api.js
|
|
||||||
+++ b/public/js/api.js
|
|
||||||
@@ -24,7 +24,7 @@ const API = {
|
|
||||||
options.body = JSON.stringify(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
- const response = await fetch(`/api${endpoint}`, options);
|
|
||||||
+ const response = await fetch(`api/${endpoint}`, options);
|
|
||||||
|
|
||||||
let result;
|
|
||||||
const contentType = response.headers.get('content-type');
|
|
||||||
@@ -39,7 +39,7 @@ const API = {
|
|
||||||
// If unauthorized, redirect to login
|
|
||||||
if (response.status === 401) {
|
|
||||||
localStorage.removeItem('authToken');
|
|
||||||
- window.location.href = '/login.html';
|
|
||||||
+ window.location.href = 'login.html';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
throw new Error(result.error || `Server responded with ${response.status}`);
|
|
||||||
@@ -50,37 +50,37 @@ const API = {
|
|
||||||
|
|
||||||
// Sources
|
|
||||||
sources: {
|
|
||||||
- getAll: () => API.request('GET', '/sources'),
|
|
||||||
- getByType: (type) => API.request('GET', `/sources/type/${type}`),
|
|
||||||
- getById: (id) => API.request('GET', `/sources/${id}`),
|
|
||||||
- create: (data) => API.request('POST', '/sources', data),
|
|
||||||
- update: (id, data) => API.request('PUT', `/sources/${id}`, data),
|
|
||||||
- delete: (id) => API.request('DELETE', `/sources/${id}`),
|
|
||||||
- toggle: (id) => API.request('POST', `/sources/${id}/toggle`),
|
|
||||||
- test: (id) => API.request('POST', `/sources/${id}/test`),
|
|
||||||
- sync: (id) => API.request('POST', `/sources/${id}/sync`), // Manual sync
|
|
||||||
- getStatus: () => API.request('GET', '/sources/status'), // Get all statuses
|
|
||||||
- estimate: (id) => API.request('GET', `/sources/${id}/estimate`), // Estimate M3U size
|
|
||||||
- estimateByUrl: (url, type) => API.request('POST', '/sources/estimate', { url, type }), // Estimate by URL (before creation)
|
|
||||||
+ getAll: () => API.request('GET', 'sources'),
|
|
||||||
+ getByType: (type) => API.request('GET', `sources/type/${type}`),
|
|
||||||
+ getById: (id) => API.request('GET', `sources/${id}`),
|
|
||||||
+ create: (data) => API.request('POST', 'sources', data),
|
|
||||||
+ update: (id, data) => API.request('PUT', `sources/${id}`, data),
|
|
||||||
+ delete: (id) => API.request('DELETE', `sources/${id}`),
|
|
||||||
+ toggle: (id) => API.request('POST', `sources/${id}/toggle`),
|
|
||||||
+ test: (id) => API.request('POST', `sources/${id}/test`),
|
|
||||||
+ sync: (id) => API.request('POST', `sources/${id}/sync`), // Manual sync
|
|
||||||
+ getStatus: () => API.request('GET', 'sources/status'), // Get all statuses
|
|
||||||
+ estimate: (id) => API.request('GET', `sources/${id}/estimate`), // Estimate M3U size
|
|
||||||
+ estimateByUrl: (url, type) => API.request('POST', 'sources/estimate', { url, type }), // Estimate by URL (before creation)
|
|
||||||
},
|
|
||||||
|
|
||||||
// Channels (hidden items)
|
|
||||||
channels: {
|
|
||||||
- getHidden: (sourceId = null) => API.request('GET', `/channels/hidden${sourceId ? `?sourceId=${sourceId}` : ''}`),
|
|
||||||
- hide: (sourceId, itemType, itemId) => API.request('POST', '/channels/hide', { sourceId, itemType, itemId }),
|
|
||||||
- show: (sourceId, itemType, itemId) => API.request('POST', '/channels/show', { sourceId, itemType, itemId }),
|
|
||||||
- isHidden: (sourceId, itemType, itemId) => API.request('GET', `/channels/hidden/check?sourceId=${sourceId}&itemType=${itemType}&itemId=${itemId}`),
|
|
||||||
- bulkHide: (items) => API.request('POST', '/channels/hide/bulk', { items }),
|
|
||||||
- bulkShow: (items) => API.request('POST', '/channels/show/bulk', { items }),
|
|
||||||
+ getHidden: (sourceId = null) => API.request('GET', `channels/hidden${sourceId ? `?sourceId=${sourceId}` : ''}`),
|
|
||||||
+ hide: (sourceId, itemType, itemId) => API.request('POST', 'channels/hide', { sourceId, itemType, itemId }),
|
|
||||||
+ show: (sourceId, itemType, itemId) => API.request('POST', 'channels/show', { sourceId, itemType, itemId }),
|
|
||||||
+ isHidden: (sourceId, itemType, itemId) => API.request('GET', `channels/hidden/check?sourceId=${sourceId}&itemType=${itemType}&itemId=${itemId}`),
|
|
||||||
+ bulkHide: (items) => API.request('POST', 'channels/hide/bulk', { items }),
|
|
||||||
+ bulkShow: (items) => API.request('POST', 'channels/show/bulk', { items }),
|
|
||||||
// Fast bulk operations - single SQL statement
|
|
||||||
- showAll: (sourceId, contentType) => API.request('POST', '/channels/show/all', { sourceId, contentType }),
|
|
||||||
- hideAll: (sourceId, contentType) => API.request('POST', '/channels/hide/all', { sourceId, contentType })
|
|
||||||
+ showAll: (sourceId, contentType) => API.request('POST', 'channels/show/all', { sourceId, contentType }),
|
|
||||||
+ hideAll: (sourceId, contentType) => API.request('POST', 'channels/hide/all', { sourceId, contentType })
|
|
||||||
},
|
|
||||||
|
|
||||||
// Favorites
|
|
||||||
favorites: {
|
|
||||||
getAll: (sourceId = null, itemType = null) => {
|
|
||||||
- let url = '/favorites';
|
|
||||||
+ let url = 'favorites';
|
|
||||||
const params = [];
|
|
||||||
if (sourceId) params.push(`sourceId=${sourceId}`);
|
|
||||||
if (itemType) params.push(`itemType=${itemType}`);
|
|
||||||
@@ -88,84 +88,84 @@ const API = {
|
|
||||||
return API.request('GET', url);
|
|
||||||
},
|
|
||||||
add: (sourceId, itemId, itemType = 'channel') =>
|
|
||||||
- API.request('POST', '/favorites', { sourceId, itemId, itemType }),
|
|
||||||
+ API.request('POST', 'favorites', { sourceId, itemId, itemType }),
|
|
||||||
remove: (sourceId, itemId, itemType = 'channel') =>
|
|
||||||
- API.request('DELETE', '/favorites', { sourceId, itemId, itemType }),
|
|
||||||
+ API.request('DELETE', 'favorites', { sourceId, itemId, itemType }),
|
|
||||||
check: (sourceId, itemId, itemType = 'channel') =>
|
|
||||||
- API.request('GET', `/favorites/check?sourceId=${sourceId}&itemId=${itemId}&itemType=${itemType}`)
|
|
||||||
+ API.request('GET', `favorites/check?sourceId=${sourceId}&itemId=${itemId}&itemType=${itemType}`)
|
|
||||||
},
|
|
||||||
|
|
||||||
// Proxy
|
|
||||||
proxy: {
|
|
||||||
// Xtream
|
|
||||||
xtream: {
|
|
||||||
- auth: (sourceId) => API.request('GET', `/proxy/xtream/${sourceId}/auth`),
|
|
||||||
+ auth: (sourceId) => API.request('GET', `proxy/xtream/${sourceId}/auth`),
|
|
||||||
liveCategories: (sourceId, options = {}) => {
|
|
||||||
const params = options.includeHidden ? '?includeHidden=true' : '';
|
|
||||||
- return API.request('GET', `/proxy/xtream/${sourceId}/live_categories${params}`);
|
|
||||||
+ return API.request('GET', `proxy/xtream/${sourceId}/live_categories${params}`);
|
|
||||||
},
|
|
||||||
liveStreams: (sourceId, categoryId = null, options = {}) => {
|
|
||||||
const params = [];
|
|
||||||
if (categoryId) params.push(`category_id=${categoryId}`);
|
|
||||||
if (options.includeHidden) params.push('includeHidden=true');
|
|
||||||
const query = params.length ? `?${params.join('&')}` : '';
|
|
||||||
- return API.request('GET', `/proxy/xtream/${sourceId}/live_streams${query}`);
|
|
||||||
+ return API.request('GET', `proxy/xtream/${sourceId}/live_streams${query}`);
|
|
||||||
},
|
|
||||||
vodCategories: (sourceId, options = {}) => {
|
|
||||||
const params = options.includeHidden ? '?includeHidden=true' : '';
|
|
||||||
- return API.request('GET', `/proxy/xtream/${sourceId}/vod_categories${params}`);
|
|
||||||
+ return API.request('GET', `proxy/xtream/${sourceId}/vod_categories${params}`);
|
|
||||||
},
|
|
||||||
vodStreams: (sourceId, categoryId = null, options = {}) => {
|
|
||||||
const params = [];
|
|
||||||
if (categoryId) params.push(`category_id=${categoryId}`);
|
|
||||||
if (options.includeHidden) params.push('includeHidden=true');
|
|
||||||
const query = params.length ? `?${params.join('&')}` : '';
|
|
||||||
- return API.request('GET', `/proxy/xtream/${sourceId}/vod_streams${query}`);
|
|
||||||
+ return API.request('GET', `proxy/xtream/${sourceId}/vod_streams${query}`);
|
|
||||||
},
|
|
||||||
seriesCategories: (sourceId, options = {}) => {
|
|
||||||
const params = options.includeHidden ? '?includeHidden=true' : '';
|
|
||||||
- return API.request('GET', `/proxy/xtream/${sourceId}/series_categories${params}`);
|
|
||||||
+ return API.request('GET', `proxy/xtream/${sourceId}/series_categories${params}`);
|
|
||||||
},
|
|
||||||
series: (sourceId, categoryId = null, options = {}) => {
|
|
||||||
const params = [];
|
|
||||||
if (categoryId) params.push(`category_id=${categoryId}`);
|
|
||||||
if (options.includeHidden) params.push('includeHidden=true');
|
|
||||||
const query = params.length ? `?${params.join('&')}` : '';
|
|
||||||
- return API.request('GET', `/proxy/xtream/${sourceId}/series${query}`);
|
|
||||||
+ return API.request('GET', `proxy/xtream/${sourceId}/series${query}`);
|
|
||||||
},
|
|
||||||
seriesInfo: (sourceId, seriesId) =>
|
|
||||||
- API.request('GET', `/proxy/xtream/${sourceId}/series_info?series_id=${seriesId}`),
|
|
||||||
- shortEpg: (sourceId, streamId) => API.request('GET', `/proxy/xtream/${sourceId}/short_epg?stream_id=${streamId}`),
|
|
||||||
+ API.request('GET', `proxy/xtream/${sourceId}/series_info?series_id=${seriesId}`),
|
|
||||||
+ shortEpg: (sourceId, streamId) => API.request('GET', `proxy/xtream/${sourceId}/short_epg?stream_id=${streamId}`),
|
|
||||||
getStreamUrl: (sourceId, streamId, type = 'live', container = 'm3u8') =>
|
|
||||||
- API.request('GET', `/proxy/xtream/${sourceId}/stream/${streamId}/${type}?container=${container}`)
|
|
||||||
+ API.request('GET', `proxy/xtream/${sourceId}/stream/${streamId}/${type}?container=${container}`)
|
|
||||||
},
|
|
||||||
|
|
||||||
// EPG
|
|
||||||
epg: {
|
|
||||||
- get: (sourceId) => API.request('GET', `/proxy/epg/${sourceId}`),
|
|
||||||
- getForChannels: (sourceId, channelIds) => API.request('POST', `/proxy/epg/${sourceId}/channels`, { channelIds })
|
|
||||||
+ get: (sourceId) => API.request('GET', `proxy/epg/${sourceId}`),
|
|
||||||
+ getForChannels: (sourceId, channelIds) => API.request('POST', `proxy/epg/${sourceId}/channels`, { channelIds })
|
|
||||||
},
|
|
||||||
|
|
||||||
// Cache management
|
|
||||||
cache: {
|
|
||||||
- clear: (sourceId) => API.request('DELETE', `/proxy/cache/${sourceId}`)
|
|
||||||
+ clear: (sourceId) => API.request('DELETE', `proxy/cache/${sourceId}`)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Settings
|
|
||||||
settings: {
|
|
||||||
- get: () => API.request('GET', '/settings'),
|
|
||||||
- update: (data) => API.request('PUT', '/settings', data),
|
|
||||||
- reset: () => API.request('DELETE', '/settings'),
|
|
||||||
- getDefaults: () => API.request('GET', '/settings/defaults')
|
|
||||||
+ get: () => API.request('GET', 'settings'),
|
|
||||||
+ update: (data) => API.request('PUT', 'settings', data),
|
|
||||||
+ reset: () => API.request('DELETE', 'settings'),
|
|
||||||
+ getDefaults: () => API.request('GET', 'settings/defaults')
|
|
||||||
},
|
|
||||||
|
|
||||||
// Users (admin only)
|
|
||||||
users: {
|
|
||||||
- getAll: () => API.request('GET', '/auth/users'),
|
|
||||||
- create: (data) => API.request('POST', '/auth/users', data),
|
|
||||||
- update: (id, data) => API.request('PUT', `/auth/users/${id}`, data),
|
|
||||||
- delete: (id) => API.request('DELETE', `/auth/users/${id}`)
|
|
||||||
+ getAll: () => API.request('GET', 'auth/users'),
|
|
||||||
+ create: (data) => API.request('POST', 'auth/users', data),
|
|
||||||
+ update: (id, data) => API.request('PUT', `auth/users/${id}`, data),
|
|
||||||
+ delete: (id) => API.request('DELETE', `auth/users/${id}`)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
diff --git a/public/js/app.js b/public/js/app.js
|
|
||||||
index c4d2f8c..f00dc38 100644
|
|
||||||
--- a/public/js/app.js
|
|
||||||
+++ b/public/js/app.js
|
|
||||||
@@ -169,13 +169,13 @@ class App {
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
// No token, redirect to login (replace to avoid back button issues)
|
|
||||||
- window.location.replace('/login.html');
|
|
||||||
+ window.location.replace('login.html');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Verify token with server
|
|
||||||
- const response = await fetch('/api/auth/me', {
|
|
||||||
+ const response = await fetch('api/auth/me', {
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${token}`
|
|
||||||
}
|
|
||||||
@@ -201,7 +201,7 @@ class App {
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Authentication error:', err);
|
|
||||||
localStorage.removeItem('authToken');
|
|
||||||
- window.location.replace('/login.html');
|
|
||||||
+ window.location.replace('login.html');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -225,7 +225,7 @@ class App {
|
|
||||||
|
|
||||||
const token = localStorage.getItem('authToken');
|
|
||||||
if (token) {
|
|
||||||
- await fetch('/api/auth/logout', {
|
|
||||||
+ await fetch('api/auth/logout', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${token}`
|
|
||||||
@@ -234,7 +234,7 @@ class App {
|
|
||||||
}
|
|
||||||
|
|
||||||
localStorage.removeItem('authToken');
|
|
||||||
- window.location.replace('/login.html');
|
|
||||||
+ window.location.replace('login.html');
|
|
||||||
});
|
|
||||||
|
|
||||||
navbar.appendChild(logoutLink);
|
|
||||||
@@ -283,7 +283,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
window.app = new App();
|
|
||||||
|
|
||||||
// Fetch and display version badge
|
|
||||||
- fetch('/api/version')
|
|
||||||
+ fetch('api/version')
|
|
||||||
.then(res => res.json())
|
|
||||||
.then(data => {
|
|
||||||
const badge = document.getElementById('version-badge');
|
|
||||||
diff --git a/public/js/components/ChannelList.js b/public/js/components/ChannelList.js
|
|
||||||
index e8da5ad..2047dbf 100644
|
|
||||||
--- a/public/js/components/ChannelList.js
|
|
||||||
+++ b/public/js/components/ChannelList.js
|
|
||||||
@@ -33,10 +33,10 @@ class ChannelList {
|
|
||||||
* Only proxies HTTP URLs when on HTTPS page
|
|
||||||
*/
|
|
||||||
getProxiedImageUrl(url) {
|
|
||||||
- if (!url || url.length === 0) return '/img/placeholder.png';
|
|
||||||
+ if (!url || url.length === 0) return 'img/placeholder.png';
|
|
||||||
// Only proxy if we're on HTTPS and the image is HTTP
|
|
||||||
if (window.location.protocol === 'https:' && url.startsWith('http://')) {
|
|
||||||
- return `/api/proxy/image?url=${encodeURIComponent(url)}`;
|
|
||||||
+ return `api/proxy/image?url=${encodeURIComponent(url)}`;
|
|
||||||
}
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
@@ -510,7 +510,7 @@ class ChannelList {
|
|
||||||
data-render-id="${renderId}"
|
|
||||||
data-render-group="${renderGroup}">
|
|
||||||
<img class="channel-logo" src="${this.getProxiedImageUrl(channel.tvgLogo)}"
|
|
||||||
- alt="" onerror="this.onerror=null;this.src='/img/placeholder.png'">
|
|
||||||
+ alt="" onerror="this.onerror=null;this.src='img/placeholder.png'">
|
|
||||||
<div class="channel-info">
|
|
||||||
<div class="channel-name">${this.escapeHtml(channel.name)}</div>
|
|
||||||
<div class="channel-program">${this.escapeHtml(this.getProgramInfo(channel) || '')}</div>
|
|
||||||
@@ -623,7 +623,7 @@ class ChannelList {
|
|
||||||
data-render-id="${renderId}"
|
|
||||||
data-render-group="${renderGroup}">
|
|
||||||
<img class="channel-logo" src="${this.getProxiedImageUrl(channel.tvgLogo)}"
|
|
||||||
- alt="" onerror="this.onerror=null;this.src='/img/placeholder.png'">
|
|
||||||
+ alt="" onerror="this.onerror=null;this.src='img/placeholder.png'">
|
|
||||||
<div class="channel-info">
|
|
||||||
<div class="channel-name">${this.escapeHtml(channel.name)}</div>
|
|
||||||
<div class="channel-program">${this.escapeHtml(this.getProgramInfo(channel) || '')}</div>
|
|
||||||
@@ -1052,7 +1052,7 @@ class ChannelList {
|
|
||||||
|
|
||||||
div.innerHTML = `
|
|
||||||
<img class="channel-logo" src="${this.getProxiedImageUrl(channel.tvgLogo)}"
|
|
||||||
- alt="" onerror="this.onerror=null;this.src='/img/placeholder.png'">
|
|
||||||
+ alt="" onerror="this.onerror=null;this.src='img/placeholder.png'">
|
|
||||||
<div class="channel-info">
|
|
||||||
<div class="channel-name">${this.escapeHtml(channel.name)}</div>
|
|
||||||
<div class="channel-program">${this.getProgramInfo(channel) || ''}</div>
|
|
||||||
@@ -1305,7 +1305,7 @@ class ChannelList {
|
|
||||||
<div class="epg-info-modal">
|
|
||||||
<div class="channel-details">
|
|
||||||
<img class="channel-logo" src="${this.getProxiedImageUrl(channel.tvgLogo)}"
|
|
||||||
- onerror="this.onerror=null;this.src='/img/placeholder.png'" />
|
|
||||||
+ onerror="this.onerror=null;this.src='img/placeholder.png'" />
|
|
||||||
<div class="channel-meta">
|
|
||||||
<p><strong>Group:</strong> ${this.escapeHtml(channel.groupTitle || 'Uncategorized')}</p>
|
|
||||||
<p><strong>Source:</strong> ${channel.sourceType}</p>
|
|
||||||
diff --git a/public/js/components/EpgGuide.js b/public/js/components/EpgGuide.js
|
|
||||||
index ce4a01d..7b0da96 100644
|
|
||||||
--- a/public/js/components/EpgGuide.js
|
|
||||||
+++ b/public/js/components/EpgGuide.js
|
|
||||||
@@ -43,10 +43,10 @@ class EpgGuide {
|
|
||||||
* Only proxies HTTP URLs when on HTTPS page
|
|
||||||
*/
|
|
||||||
getProxiedImageUrl(url) {
|
|
||||||
- if (!url || url.length === 0) return '/img/placeholder.png';
|
|
||||||
+ if (!url || url.length === 0) return 'img/placeholder.png';
|
|
||||||
// Only proxy if we're on HTTPS and the image is HTTP
|
|
||||||
if (window.location.protocol === 'https:' && url.startsWith('http://')) {
|
|
||||||
- return `/api/proxy/image?url=${encodeURIComponent(url)}`;
|
|
||||||
+ return `api/proxy/image?url=${encodeURIComponent(url)}`;
|
|
||||||
}
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
@@ -212,7 +212,7 @@ class EpgGuide {
|
|
||||||
// Load EPG from ALL sources in parallel
|
|
||||||
const fetchPromises = sources.map(async (source) => {
|
|
||||||
try {
|
|
||||||
- const response = await fetch(`/api/proxy/epg/${source.id}${queryParams}`);
|
|
||||||
+ const response = await fetch(`api/proxy/epg/${source.id}${queryParams}`);
|
|
||||||
if (!response.ok) throw new Error(`Status ${response.status}`);
|
|
||||||
return await response.json();
|
|
||||||
} catch (e) {
|
|
||||||
@@ -596,7 +596,7 @@ class EpgGuide {
|
|
||||||
${isFavorite ? Icons.favorite : Icons.favoriteOutline}
|
|
||||||
</button>
|
|
||||||
<img class="epg-channel-logo" src="${logo}"
|
|
||||||
- alt="" onerror="this.onerror=null;this.src='/img/placeholder.png'">
|
|
||||||
+ alt="" onerror="this.onerror=null;this.src='img/placeholder.png'">
|
|
||||||
<span class="epg-channel-name">${name}</span>
|
|
||||||
<div class="resize-handle"></div>
|
|
||||||
</div>
|
|
||||||
diff --git a/public/js/components/VideoPlayer.js b/public/js/components/VideoPlayer.js
|
|
||||||
index a089dab..ac3b71c 100644
|
|
||||||
--- a/public/js/components/VideoPlayer.js
|
|
||||||
+++ b/public/js/components/VideoPlayer.js
|
|
||||||
@@ -805,7 +805,7 @@ class VideoPlayer {
|
|
||||||
async startTranscodeSession(url, options = {}) {
|
|
||||||
try {
|
|
||||||
console.log('[Player] Starting HLS transcode session...', options);
|
|
||||||
- const res = await fetch('/api/transcode/session', {
|
|
||||||
+ const res = await fetch('api/transcode/session', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ url, ...options })
|
|
||||||
@@ -817,7 +817,7 @@ class VideoPlayer {
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[Player] Session start failed:', err);
|
|
||||||
// Fallback to direct transcode if session fails
|
|
||||||
- return `/api/transcode?url=${encodeURIComponent(url)}`;
|
|
||||||
+ return `api/transcode?url=${encodeURIComponent(url)}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -829,7 +829,7 @@ class VideoPlayer {
|
|
||||||
console.log('[Player] Stopping transcode session:', this.currentSessionId);
|
|
||||||
try {
|
|
||||||
// Fire and forget cleanup
|
|
||||||
- fetch(`/api/transcode/${this.currentSessionId}`, { method: 'DELETE' });
|
|
||||||
+ fetch(`api/transcode/${this.currentSessionId}`, { method: 'DELETE' });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to stop session:', err);
|
|
||||||
}
|
|
||||||
@@ -865,7 +865,7 @@ class VideoPlayer {
|
|
||||||
if (this.settings.autoTranscode) {
|
|
||||||
console.log('[Player] Auto Transcode enabled. Probing stream...');
|
|
||||||
try {
|
|
||||||
- const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(streamUrl)}`);
|
|
||||||
+ const probeRes = await fetch(`api/probe?url=${encodeURIComponent(streamUrl)}`);
|
|
||||||
const info = await probeRes.json();
|
|
||||||
console.log(`[Player] Probe result: video=${info.video}, audio=${info.audio}, ${info.width}x${info.height}, compatible=${info.compatible}`);
|
|
||||||
|
|
||||||
@@ -885,7 +885,7 @@ class VideoPlayer {
|
|
||||||
track.kind = 'subtitles';
|
|
||||||
track.label = sub.title;
|
|
||||||
track.srclang = sub.language;
|
|
||||||
- track.src = `/api/subtitle?url=${encodeURIComponent(streamUrl)}&index=${sub.index}`;
|
|
||||||
+ track.src = `api/subtitle?url=${encodeURIComponent(streamUrl)}&index=${sub.index}`;
|
|
||||||
this.video.appendChild(track);
|
|
||||||
});
|
|
||||||
|
|
||||||
@@ -925,7 +925,7 @@ class VideoPlayer {
|
|
||||||
// Raw .ts container - use remux
|
|
||||||
console.log('[Player] Auto: Using remux (.ts container)');
|
|
||||||
this.updateTranscodeStatus('remuxing', 'Remux (Auto)');
|
|
||||||
- const remuxUrl = `/api/remux?url=${encodeURIComponent(streamUrl)}`;
|
|
||||||
+ const remuxUrl = `api/remux?url=${encodeURIComponent(streamUrl)}`;
|
|
||||||
this.currentUrl = remuxUrl;
|
|
||||||
this.video.src = remuxUrl;
|
|
||||||
this.video.play().catch(e => {
|
|
||||||
@@ -997,7 +997,7 @@ class VideoPlayer {
|
|
||||||
// Probe to get video codec for HEVC tag handling
|
|
||||||
let videoCodec = 'unknown';
|
|
||||||
try {
|
|
||||||
- const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(streamUrl)}`);
|
|
||||||
+ const probeRes = await fetch(`api/probe?url=${encodeURIComponent(streamUrl)}`);
|
|
||||||
const info = await probeRes.json();
|
|
||||||
videoCodec = info.video;
|
|
||||||
} catch (e) { console.warn('Probe failed for force audio, assuming h264'); }
|
|
||||||
@@ -1096,7 +1096,7 @@ class VideoPlayer {
|
|
||||||
(data.type === Hls.ErrorTypes.MEDIA_ERROR && data.details === 'fragParsingError');
|
|
||||||
|
|
||||||
// Don't proxy if it's already a local API URL
|
|
||||||
- const isLocalApi = this.currentUrl.startsWith('/api/');
|
|
||||||
+ const isLocalApi = this.currentUrl.startsWith('api/');
|
|
||||||
|
|
||||||
if (isCorsLikely && !this.isUsingProxy && !isLocalApi) {
|
|
||||||
console.log('CORS/Network error detected, retrying via proxy...', data.details);
|
|
||||||
@@ -1344,14 +1344,14 @@ class VideoPlayer {
|
|
||||||
* Get proxied URL for a stream
|
|
||||||
*/
|
|
||||||
getProxiedUrl(url) {
|
|
||||||
- return `/api/proxy/stream?url=${encodeURIComponent(url)}`;
|
|
||||||
+ return `api/proxy/stream?url=${encodeURIComponent(url)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get transcoded URL for a stream (audio transcoding for browser compatibility)
|
|
||||||
*/
|
|
||||||
getTranscodeUrl(url) {
|
|
||||||
- return `/api/transcode?url=${encodeURIComponent(url)}`;
|
|
||||||
+ return `api/transcode?url=${encodeURIComponent(url)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
@@ -1359,7 +1359,7 @@ class VideoPlayer {
|
|
||||||
* Used for raw .ts streams that browsers can't play directly
|
|
||||||
*/
|
|
||||||
getRemuxUrl(url) {
|
|
||||||
- return `/api/remux?url=${encodeURIComponent(url)}`;
|
|
||||||
+ return `api/remux?url=${encodeURIComponent(url)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
diff --git a/public/js/login.js b/public/js/login.js
|
|
||||||
index a246d10..e2339f3 100644
|
|
||||||
--- a/public/js/login.js
|
|
||||||
+++ b/public/js/login.js
|
|
||||||
@@ -5,10 +5,10 @@ document.getElementById('login-form').addEventListener('submit', async (e) => {
|
|
||||||
const password = document.getElementById('password').value;
|
|
||||||
|
|
||||||
try {
|
|
||||||
- const response = await API.request('POST', '/auth/login', { username, password });
|
|
||||||
+ const response = await API.request('POST', 'auth/login', { username, password });
|
|
||||||
localStorage.setItem('sessionToken', response.token);
|
|
||||||
- window.location.href = '/';
|
|
||||||
+ window.location.href = 'index.html';
|
|
||||||
} catch (err) {
|
|
||||||
document.getElementById('login-error').textContent = 'Login failed: ' + err.message;
|
|
||||||
}
|
|
||||||
-});
|
|
||||||
\ No newline at end of file
|
|
||||||
+});
|
|
||||||
diff --git a/public/js/pages/HomePage.js b/public/js/pages/HomePage.js
|
|
||||||
index 6ddadc1..c8a1805 100644
|
|
||||||
--- a/public/js/pages/HomePage.js
|
|
||||||
+++ b/public/js/pages/HomePage.js
|
|
||||||
@@ -174,7 +174,7 @@ class HomePage {
|
|
||||||
await this.renderFavoriteChannels();
|
|
||||||
|
|
||||||
// 1. Load Watch History
|
|
||||||
- const history = await window.API.request('GET', '/history?limit=12');
|
|
||||||
+ const history = await window.API.request('GET', 'history?limit=12');
|
|
||||||
if (history && Array.isArray(history)) {
|
|
||||||
this.renderHistory(history);
|
|
||||||
}
|
|
||||||
@@ -197,7 +197,7 @@ class HomePage {
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Fetch favorite channels for current user
|
|
||||||
- const favorites = await window.API.request('GET', '/favorites?itemType=channel');
|
|
||||||
+ const favorites = await window.API.request('GET', 'favorites?itemType=channel');
|
|
||||||
|
|
||||||
if (!favorites || favorites.length === 0) {
|
|
||||||
list.innerHTML = '<div class="empty-state hint">Add channels to favorites from Live TV</div>';
|
|
||||||
@@ -251,14 +251,14 @@ class HomePage {
|
|
||||||
}
|
|
||||||
|
|
||||||
createChannelTile(channel) {
|
|
||||||
- const logo = channel.tvgLogo || '/img/placeholder.png';
|
|
||||||
- const logoUrl = logo.startsWith('http') ? `/api/proxy/image?url=${encodeURIComponent(logo)}` : logo;
|
|
||||||
+ const logo = channel.tvgLogo || 'img/placeholder.png';
|
|
||||||
+ const logoUrl = logo.startsWith('http') ? `api/proxy/image?url=${encodeURIComponent(logo)}` : logo;
|
|
||||||
const name = channel.name || 'Unknown';
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="channel-tile" data-channel-id="${channel.id}" data-source-id="${channel.sourceId}">
|
|
||||||
<div class="tile-logo">
|
|
||||||
- <img src="${logoUrl}" alt="${name}" loading="lazy" onerror="this.onerror=null;this.src='/img/placeholder.png'">
|
|
||||||
+ <img src="${logoUrl}" alt="${name}" loading="lazy" onerror="this.onerror=null;this.src='img/placeholder.png'">
|
|
||||||
</div>
|
|
||||||
<div class="tile-name" title="${name}">${name}</div>
|
|
||||||
</div>
|
|
||||||
@@ -352,7 +352,7 @@ class HomePage {
|
|
||||||
if (!list) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
- const movies = await window.API.request('GET', '/channels/recent?type=movie&limit=12');
|
|
||||||
+ const movies = await window.API.request('GET', 'channels/recent?type=movie&limit=12');
|
|
||||||
if (!movies || movies.length === 0) {
|
|
||||||
list.innerHTML = '<div class="empty-state hint">No recently added movies found</div>';
|
|
||||||
return;
|
|
||||||
@@ -381,7 +381,7 @@ class HomePage {
|
|
||||||
if (!list) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
- const series = await window.API.request('GET', '/channels/recent?type=series&limit=12');
|
|
||||||
+ const series = await window.API.request('GET', 'channels/recent?type=series&limit=12');
|
|
||||||
if (!series || series.length === 0) {
|
|
||||||
list.innerHTML = '<div class="empty-state hint">No recently added series found</div>';
|
|
||||||
return;
|
|
||||||
@@ -411,13 +411,13 @@ class HomePage {
|
|
||||||
const percent = Math.min(100, Math.round((progress / duration) * 100));
|
|
||||||
|
|
||||||
// Proxy the poster if it's an external URL
|
|
||||||
- const poster = data.poster || '/img/poster-placeholder.jpg';
|
|
||||||
- const posterUrl = poster.startsWith('http') ? `/api/proxy/image?url=${encodeURIComponent(poster)}` : poster;
|
|
||||||
+ const poster = data.poster || 'img/poster-placeholder.jpg';
|
|
||||||
+ const posterUrl = poster.startsWith('http') ? `api/proxy/image?url=${encodeURIComponent(poster)}` : poster;
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="dashboard-card" data-id="${item_id}" data-type="${type}">
|
|
||||||
<div class="card-image">
|
|
||||||
- <img src="${posterUrl}" alt="${data.title || item.name}" loading="lazy" onerror="this.onerror=null;this.src='/img/poster-placeholder.jpg'">
|
|
||||||
+ <img src="${posterUrl}" alt="${data.title || item.name}" loading="lazy" onerror="this.onerror=null;this.src='img/poster-placeholder.jpg'">
|
|
||||||
<div class="progress-bar-container">
|
|
||||||
<div class="progress-bar" style="width: ${percent}%"></div>
|
|
||||||
</div>
|
|
||||||
@@ -436,13 +436,13 @@ class HomePage {
|
|
||||||
createRecentCard(item) {
|
|
||||||
const { data, item_id } = item;
|
|
||||||
const type = item.type || item.item_type;
|
|
||||||
- const poster = item.stream_icon || data.poster || '/img/poster-placeholder.jpg';
|
|
||||||
- const posterUrl = poster.startsWith('http') ? `/api/proxy/image?url=${encodeURIComponent(poster)}` : poster;
|
|
||||||
+ const poster = item.stream_icon || data.poster || 'img/poster-placeholder.jpg';
|
|
||||||
+ const posterUrl = poster.startsWith('http') ? `api/proxy/image?url=${encodeURIComponent(poster)}` : poster;
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="dashboard-card" data-id="${item_id}" data-type="${type}">
|
|
||||||
<div class="card-image">
|
|
||||||
- <img src="${posterUrl}" alt="${item.name}" loading="lazy" onerror="this.onerror=null;this.src='/img/poster-placeholder.jpg'">
|
|
||||||
+ <img src="${posterUrl}" alt="${item.name}" loading="lazy" onerror="this.onerror=null;this.src='img/poster-placeholder.jpg'">
|
|
||||||
<div class="play-icon-overlay">
|
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
|
|
||||||
</div>
|
|
||||||
@@ -465,7 +465,7 @@ class HomePage {
|
|
||||||
const streamId = item.item_id;
|
|
||||||
const container = item.container_extension || (item.data && item.data.containerExtension) || 'mp4';
|
|
||||||
|
|
||||||
- const result = await window.API.request('GET', `/proxy/xtream/${sourceId}/stream/${streamId}/${streamType}?container=${container}`);
|
|
||||||
+ const result = await window.API.request('GET', `proxy/xtream/${sourceId}/stream/${streamId}/${streamType}?container=${container}`);
|
|
||||||
|
|
||||||
if (result && result.url) {
|
|
||||||
const content = {
|
|
||||||
@@ -488,7 +488,7 @@ class HomePage {
|
|
||||||
// Fetch seriesInfo if we have a seriesId
|
|
||||||
if (content.seriesId && sourceId) {
|
|
||||||
try {
|
|
||||||
- const seriesInfo = await window.API.request('GET', `/proxy/xtream/${sourceId}/series_info?series_id=${content.seriesId}`);
|
|
||||||
+ const seriesInfo = await window.API.request('GET', `proxy/xtream/${sourceId}/series_info?series_id=${content.seriesId}`);
|
|
||||||
if (seriesInfo) {
|
|
||||||
content.seriesInfo = seriesInfo;
|
|
||||||
}
|
|
||||||
diff --git a/public/js/pages/MoviesPage.js b/public/js/pages/MoviesPage.js
|
|
||||||
index a5bb40f..c27fba6 100644
|
|
||||||
--- a/public/js/pages/MoviesPage.js
|
|
||||||
+++ b/public/js/pages/MoviesPage.js
|
|
||||||
@@ -278,7 +278,7 @@ class MoviesPage {
|
|
||||||
card.dataset.movieId = movie.stream_id;
|
|
||||||
card.dataset.sourceId = movie.sourceId;
|
|
||||||
|
|
||||||
- const poster = movie.stream_icon || movie.cover || '/img/placeholder.png';
|
|
||||||
+ const poster = movie.stream_icon || movie.cover || 'img/placeholder.png';
|
|
||||||
const year = movie.year || movie.releaseDate?.substring(0, 4) || '';
|
|
||||||
const rating = movie.rating ? `${Icons.star} ${movie.rating}` : '';
|
|
||||||
|
|
||||||
@@ -287,7 +287,7 @@ class MoviesPage {
|
|
||||||
card.innerHTML = `
|
|
||||||
<div class="movie-poster">
|
|
||||||
<img src="${poster}" alt="${movie.name}"
|
|
||||||
- onerror="this.onerror=null;this.src='/img/placeholder.png'" loading="lazy">
|
|
||||||
+ onerror="this.onerror=null;this.src='img/placeholder.png'" loading="lazy">
|
|
||||||
<div class="movie-play-overlay">
|
|
||||||
<span class="play-icon">${Icons.play}</span>
|
|
||||||
</div>
|
|
||||||
diff --git a/public/js/pages/SeriesPage.js b/public/js/pages/SeriesPage.js
|
|
||||||
index 6b7a929..1a25f85 100644
|
|
||||||
--- a/public/js/pages/SeriesPage.js
|
|
||||||
+++ b/public/js/pages/SeriesPage.js
|
|
||||||
@@ -288,7 +288,7 @@ class SeriesPage {
|
|
||||||
card.dataset.seriesId = series.series_id;
|
|
||||||
card.dataset.sourceId = series.sourceId;
|
|
||||||
|
|
||||||
- const poster = series.cover || '/img/placeholder.png';
|
|
||||||
+ const poster = series.cover || 'img/placeholder.png';
|
|
||||||
const year = series.year || series.releaseDate?.substring(0, 4) || '';
|
|
||||||
const rating = series.rating ? `${Icons.star} ${series.rating}` : '';
|
|
||||||
|
|
||||||
@@ -297,7 +297,7 @@ class SeriesPage {
|
|
||||||
card.innerHTML = `
|
|
||||||
<div class="series-poster">
|
|
||||||
<img src="${poster}" alt="${series.name}"
|
|
||||||
- onerror="this.onerror=null;this.src='/img/placeholder.png'" loading="lazy">
|
|
||||||
+ onerror="this.onerror=null;this.src='img/placeholder.png'" loading="lazy">
|
|
||||||
<div class="series-play-overlay">
|
|
||||||
<span class="play-icon">${Icons.play}</span>
|
|
||||||
</div>
|
|
||||||
@@ -350,7 +350,7 @@ class SeriesPage {
|
|
||||||
this.detailsPanel.classList.remove('hidden');
|
|
||||||
|
|
||||||
// Set header info
|
|
||||||
- document.getElementById('series-poster').src = series.cover || '/img/placeholder.png';
|
|
||||||
+ document.getElementById('series-poster').src = series.cover || 'img/placeholder.png';
|
|
||||||
document.getElementById('series-title').textContent = series.name;
|
|
||||||
document.getElementById('series-plot').textContent = series.plot || '';
|
|
||||||
|
|
||||||
diff --git a/public/js/pages/Settings.js b/public/js/pages/Settings.js
|
|
||||||
index a47b5d7..437d7a5 100644
|
|
||||||
--- a/public/js/pages/Settings.js
|
|
||||||
+++ b/public/js/pages/Settings.js
|
|
||||||
@@ -259,7 +259,7 @@ class SettingsPage {
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
- const response = await fetch('/api/settings/hw-info');
|
|
||||||
+ const response = await fetch('api/settings/hw-info');
|
|
||||||
if (!response.ok) throw new Error('Failed to fetch hardware info');
|
|
||||||
const hwInfo = await response.json();
|
|
||||||
|
|
||||||
@@ -603,7 +603,7 @@ class SettingsPage {
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Fetch last sync time from server
|
|
||||||
- const response = await fetch('/api/settings/sync-status');
|
|
||||||
+ const response = await fetch('api/settings/sync-status');
|
|
||||||
if (!response.ok) throw new Error('Failed to fetch sync status');
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
diff --git a/public/js/pages/WatchPage.js b/public/js/pages/WatchPage.js
|
|
||||||
index b85ca63..2aecdfd 100644
|
|
||||||
--- a/public/js/pages/WatchPage.js
|
|
||||||
+++ b/public/js/pages/WatchPage.js
|
|
||||||
@@ -329,7 +329,7 @@ class WatchPage {
|
|
||||||
async startTranscodeSession(url, options = {}) {
|
|
||||||
try {
|
|
||||||
console.log('[WatchPage] Starting HLS transcode session...', options);
|
|
||||||
- const res = await fetch('/api/transcode/session', {
|
|
||||||
+ const res = await fetch('api/transcode/session', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
@@ -345,7 +345,7 @@ class WatchPage {
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[WatchPage] Session start failed:', err);
|
|
||||||
// Fallback to direct transcode if session fails
|
|
||||||
- return `/api/transcode?url=${encodeURIComponent(url)}`;
|
|
||||||
+ return `api/transcode?url=${encodeURIComponent(url)}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -357,7 +357,7 @@ class WatchPage {
|
|
||||||
console.log('[WatchPage] Stopping transcode session:', this.currentSessionId);
|
|
||||||
try {
|
|
||||||
// Fire and forget cleanup
|
|
||||||
- fetch(`/api/transcode/${this.currentSessionId}`, { method: 'DELETE' });
|
|
||||||
+ fetch(`api/transcode/${this.currentSessionId}`, { method: 'DELETE' });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to stop session:', err);
|
|
||||||
}
|
|
||||||
@@ -437,7 +437,7 @@ class WatchPage {
|
|
||||||
console.log('[WatchPage] Auto Transcode enabled. Probing stream...');
|
|
||||||
try {
|
|
||||||
const ua = settings.userAgentPreset === 'custom' ? settings.userAgentCustom : settings.userAgentPreset;
|
|
||||||
- const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(url)}&ua=${encodeURIComponent(ua || '')}`);
|
|
||||||
+ const probeRes = await fetch(`api/probe?url=${encodeURIComponent(url)}&ua=${encodeURIComponent(ua || '')}`);
|
|
||||||
const info = await probeRes.json();
|
|
||||||
console.log(`[WatchPage] Probe result: video=${info.video}, audio=${info.audio}, ${info.width}x${info.height}, compatible=${info.compatible}`);
|
|
||||||
|
|
||||||
@@ -470,7 +470,7 @@ class WatchPage {
|
|
||||||
// TODO: Move remux to session logic if seeking is needed for TS files
|
|
||||||
console.log('[WatchPage] Auto: Using remux (.ts container)');
|
|
||||||
this.updateTranscodeStatus('remuxing', 'Remux (Auto)');
|
|
||||||
- const finalUrl = `/api/remux?url=${encodeURIComponent(url)}`;
|
|
||||||
+ const finalUrl = `api/remux?url=${encodeURIComponent(url)}`;
|
|
||||||
this.video.src = finalUrl;
|
|
||||||
this.video.play().catch(e => {
|
|
||||||
if (e.name !== 'AbortError') console.error('[WatchPage] Autoplay error:', e);
|
|
||||||
@@ -509,7 +509,7 @@ class WatchPage {
|
|
||||||
let videoCodec = 'unknown';
|
|
||||||
try {
|
|
||||||
const ua = settings.userAgentPreset === 'custom' ? settings.userAgentCustom : settings.userAgentPreset;
|
|
||||||
- const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(url)}&ua=${encodeURIComponent(ua || '')}`);
|
|
||||||
+ const probeRes = await fetch(`api/probe?url=${encodeURIComponent(url)}&ua=${encodeURIComponent(ua || '')}`);
|
|
||||||
const info = await probeRes.json();
|
|
||||||
videoCodec = info.video;
|
|
||||||
} catch (e) { console.warn('Probe failed for force audio, assuming h264'); }
|
|
||||||
@@ -528,7 +528,7 @@ class WatchPage {
|
|
||||||
if (settings.forceRemux && isRawTs) {
|
|
||||||
console.log('[WatchPage] Force Remux enabled');
|
|
||||||
this.updateTranscodeStatus('remuxing', 'Remux (Force)');
|
|
||||||
- const finalUrl = `/api/remux?url=${encodeURIComponent(url)}`;
|
|
||||||
+ const finalUrl = `api/remux?url=${encodeURIComponent(url)}`;
|
|
||||||
this.video.src = finalUrl;
|
|
||||||
this.video.play().catch(e => {
|
|
||||||
if (e.name !== 'AbortError') console.error('[WatchPage] Autoplay error:', e);
|
|
||||||
@@ -540,7 +540,7 @@ class WatchPage {
|
|
||||||
// Determine if proxy is needed
|
|
||||||
const proxyRequiredDomains = ['pluto.tv'];
|
|
||||||
const needsProxy = settings.forceProxy || proxyRequiredDomains.some(domain => url.includes(domain));
|
|
||||||
- const finalUrl = needsProxy ? `/api/proxy/stream?url=${encodeURIComponent(url)}` : url;
|
|
||||||
+ const finalUrl = needsProxy ? `api/proxy/stream?url=${encodeURIComponent(url)}` : url;
|
|
||||||
|
|
||||||
console.log('[WatchPage] Playing:', { url, needsProxy, looksLikeHls });
|
|
||||||
|
|
||||||
@@ -600,9 +600,9 @@ class WatchPage {
|
|
||||||
console.error('[WatchPage] HLS fatal error:', data);
|
|
||||||
// Try proxy on CORS error (only if not already proxied/transcoded)
|
|
||||||
// Note: Transcoded streams are local, so no CORS issues usually
|
|
||||||
- if (!url.startsWith('/api/') && (data.type === Hls.ErrorTypes.NETWORK_ERROR)) {
|
|
||||||
+ if (!url.startsWith('api/') && (data.type === Hls.ErrorTypes.NETWORK_ERROR)) {
|
|
||||||
console.log('[WatchPage] Retrying via proxy...');
|
|
||||||
- this.playHls(`/api/proxy/stream?url=${encodeURIComponent(this.currentUrl)}`);
|
|
||||||
+ this.playHls(`api/proxy/stream?url=${encodeURIComponent(this.currentUrl)}`);
|
|
||||||
} else {
|
|
||||||
this.hls.destroy();
|
|
||||||
}
|
|
||||||
@@ -1030,7 +1030,7 @@ class WatchPage {
|
|
||||||
if (!this.content) return;
|
|
||||||
|
|
||||||
const isChannel = this.content.type === 'channel' || !this.content.type; // Default to channel if unknown
|
|
||||||
- const fallback = isChannel ? '/img/placeholder.png' : '/img/poster-placeholder.jpg';
|
|
||||||
+ const fallback = isChannel ? 'img/placeholder.png' : 'img/poster-placeholder.jpg';
|
|
||||||
|
|
||||||
this.posterEl.onerror = () => {
|
|
||||||
this.posterEl.onerror = null;
|
|
||||||
@@ -1130,9 +1130,9 @@ class WatchPage {
|
|
||||||
|
|
||||||
this.recommendedGrid.innerHTML = movies.map(movie => `
|
|
||||||
<div class="watch-recommended-card" data-id="${movie.stream_id}" data-source="${sourceId}">
|
|
||||||
- <img src="${movie.stream_icon || movie.cover || '/img/placeholder.png'}"
|
|
||||||
+ <img src="${movie.stream_icon || movie.cover || 'img/placeholder.png'}"
|
|
||||||
alt="${movie.name}"
|
|
||||||
- onerror="this.onerror=null;this.src='/img/placeholder.png'" loading="lazy">
|
|
||||||
+ onerror="this.onerror=null;this.src='img/placeholder.png'" loading="lazy">
|
|
||||||
<p>${movie.name}</p>
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
@@ -1417,7 +1417,7 @@ class WatchPage {
|
|
||||||
currentEpisode: this.currentEpisode || null
|
|
||||||
};
|
|
||||||
|
|
||||||
- await window.API.request('POST', '/history', {
|
|
||||||
+ await window.API.request('POST', 'history', {
|
|
||||||
id: this.content.id,
|
|
||||||
type: this.content.type === 'movie' ? 'movie' : 'episode',
|
|
||||||
sourceId: this.content.sourceId,
|
|
||||||
diff --git a/public/login.html b/public/login.html
|
|
||||||
index 454acfe..34bcb46 100644
|
|
||||||
--- a/public/login.html
|
|
||||||
+++ b/public/login.html
|
|
||||||
@@ -5,7 +5,7 @@
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Login - NodeCast TV</title>
|
|
||||||
- <link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
|
||||||
+ <link rel="icon" type="image/svg+xml" href="favicon.svg">
|
|
||||||
<link rel="stylesheet" href="css/main.css">
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
@@ -228,7 +228,7 @@
|
|
||||||
<script>
|
|
||||||
// Start SSO Login
|
|
||||||
document.getElementById('btn-sso-login').addEventListener('click', () => {
|
|
||||||
- window.location.href = '/api/auth/oidc/login';
|
|
||||||
+ window.location.href = 'api/auth/oidc/login';
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check for URL Error params
|
|
||||||
@@ -247,7 +247,7 @@
|
|
||||||
|
|
||||||
async function checkSetupRequired() {
|
|
||||||
try {
|
|
||||||
- const response = await fetch('/api/auth/setup-required');
|
|
||||||
+ const response = await fetch('api/auth/setup-required');
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.setupRequired) {
|
|
||||||
@@ -283,7 +283,7 @@
|
|
||||||
submitBtn.textContent = 'Please wait...';
|
|
||||||
|
|
||||||
try {
|
|
||||||
- const endpoint = isSetupMode ? '/api/auth/setup' : '/api/auth/login';
|
|
||||||
+ const endpoint = isSetupMode ? 'api/auth/setup' : 'api/auth/login';
|
|
||||||
const response = await fetch(endpoint, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
@@ -302,7 +302,7 @@
|
|
||||||
localStorage.setItem('authToken', data.token);
|
|
||||||
|
|
||||||
// Redirect to main app (replace history to prevent back button issues)
|
|
||||||
- window.location.replace('/');
|
|
||||||
+ window.location.replace('index.html');
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
errorMessage.textContent = err.message;
|
|
||||||
@@ -317,4 +317,4 @@
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
-</html>
|
|
||||||
\ No newline at end of file
|
|
||||||
+</html>
|
|
||||||
Generated
+4
-4
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "kiratv",
|
"name": "nodecast-tv",
|
||||||
"version": "2.1.4-kira1",
|
"version": "2.1.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "kiratv",
|
"name": "nodecast-tv",
|
||||||
"version": "2.1.4-kira1",
|
"version": "2.1.4",
|
||||||
"license": "GPL-3.0-only",
|
"license": "GPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ffprobe-installer/ffprobe": "^2.1.2",
|
"@ffprobe-installer/ffprobe": "^2.1.2",
|
||||||
|
|||||||
+3
-3
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "kiratv",
|
"name": "nodecast-tv",
|
||||||
"version": "2.1.4-kira1",
|
"version": "2.1.4",
|
||||||
"description": "KiraTV — reproductor IPTV para el navegador (fork de NodeCast)",
|
"description": "Modern IPTV Player",
|
||||||
"license": "GPL-3.0-only",
|
"license": "GPL-3.0-only",
|
||||||
"main": "server/index.js",
|
"main": "server/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+2
-10
@@ -1,11 +1,3 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 187.63 210.6">
|
||||||
<defs>
|
<path fill="#6366f1" d="M169.71,87.87c-4.77,0-9.1,1.88-12.31,4.92L35.3,22.3c.35-1.4.56-2.86.56-4.37C35.86,8.03,27.83,0,17.93,0S0,8.03,0,17.93c0,8.44,5.84,15.5,13.69,17.4v140c-7.71,2.01-13.41,8.99-13.41,17.33,0,9.9,8.03,17.93,17.93,17.93s17.93-8.03,17.93-17.93c0-1.56-.22-3.07-.6-4.51l121.21-69.98c3.26,3.42,7.86,5.55,12.95,5.55,9.9,0,17.93-8.03,17.93-17.93s-8.03-17.93-17.93-17.93ZM152.78,99.92c-.14.41-.27.83-.38,1.25h-51.47l-44.18-24.72L30.4,30.8c.33-.32.64-.65.94-.99l121.45,70.12ZM22.17,175.2V35.33c.23-.05.45-.11.67-.17l26.35,45.65-.34,50.04-25.74,44.59c-.31-.09-.63-.17-.95-.24ZM31.52,180.69c-.27-.3-.55-.6-.84-.89l25.48-44.13,44.64-25.78h51.47c.07.32.15.63.24.94L31.52,180.69Z"/>
|
||||||
<linearGradient id="g" x1="0" y1="0" x2="64" y2="64">
|
|
||||||
<stop offset="0%" stop-color="#7c3aed"/>
|
|
||||||
<stop offset="100%" stop-color="#4f46e5"/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<rect width="64" height="64" rx="14" fill="url(#g)"/>
|
|
||||||
<rect x="10" y="18" width="44" height="28" rx="4" stroke="#fff" stroke-width="3" fill="none"/>
|
|
||||||
<path d="M22 32 L28 38 L42 24" stroke="#fff" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 553 B After Width: | Height: | Size: 764 B |
+4
-4
@@ -1,5 +1,5 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="es">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
@@ -7,8 +7,8 @@
|
|||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
<meta name="mobile-web-app-capable" content="yes">
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
<title>KiraTV | Televisión en el navegador</title>
|
<title>nodecast-tv | IPTV Player</title>
|
||||||
<meta name="description" content="TV en directo, películas y series con guía EPG — KiraTV">
|
<meta name="description" content="Stream live TV, movies, and series with EPG support">
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
<link rel="stylesheet" href="css/main.css">
|
<link rel="stylesheet" href="css/main.css">
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
<path
|
<path
|
||||||
d="M169.71,87.87c-4.77,0-9.1,1.88-12.31,4.92L35.3,22.3c.35-1.4.56-2.86.56-4.37C35.86,8.03,27.83,0,17.93,0S0,8.03,0,17.93c0,8.44,5.84,15.5,13.69,17.4v140c-7.71,2.01-13.41,8.99-13.41,17.33,0,9.9,8.03,17.93,17.93,17.93s17.93-8.03,17.93-17.93c0-1.56-.22-3.07-.6-4.51l121.21-69.98c3.26,3.42,7.86,5.55,12.95,5.55,9.9,0,17.93-8.03,17.93-17.93s-8.03-17.93-17.93-17.93ZM152.78,99.92c-.14.41-.27.83-.38,1.25h-51.47l-44.18-24.72L30.4,30.8c.33-.32.64-.65.94-.99l121.45,70.12ZM22.17,175.2V35.33c.23-.05.45-.11.67-.17l26.35,45.65-.34,50.04-25.74,44.59c-.31-.09-.63-.17-.95-.24ZM31.52,180.69c-.27-.3-.55-.6-.84-.89l25.48-44.13,44.64-25.78h51.47c.07.32.15.63.24.94L31.52,180.69Z" />
|
d="M169.71,87.87c-4.77,0-9.1,1.88-12.31,4.92L35.3,22.3c.35-1.4.56-2.86.56-4.37C35.86,8.03,27.83,0,17.93,0S0,8.03,0,17.93c0,8.44,5.84,15.5,13.69,17.4v140c-7.71,2.01-13.41,8.99-13.41,17.33,0,9.9,8.03,17.93,17.93,17.93s17.93-8.03,17.93-17.93c0-1.56-.22-3.07-.6-4.51l121.21-69.98c3.26,3.42,7.86,5.55,12.95,5.55,9.9,0,17.93-8.03,17.93-17.93s-8.03-17.93-17.93-17.93ZM152.78,99.92c-.14.41-.27.83-.38,1.25h-51.47l-44.18-24.72L30.4,30.8c.33-.32.64-.65.94-.99l121.45,70.12ZM22.17,175.2V35.33c.23-.05.45-.11.67-.17l26.35,45.65-.34,50.04-25.74,44.59c-.31-.09-.63-.17-.95-.24ZM31.52,180.69c-.27-.3-.55-.6-.84-.89l25.48-44.13,44.64-25.78h51.47c.07.32.15.63.24.94L31.52,180.69Z" />
|
||||||
</svg></span>
|
</svg></span>
|
||||||
<span class="brand-text">KiraTV</span>
|
<span class="brand-text">nodecast-tv</span>
|
||||||
<span id="version-badge" class="version-badge"></span>
|
<span id="version-badge" class="version-badge"></span>
|
||||||
</div>
|
</div>
|
||||||
<button class="mobile-menu-toggle" id="mobile-menu-toggle" aria-label="Toggle menu">
|
<button class="mobile-menu-toggle" id="mobile-menu-toggle" aria-label="Toggle menu">
|
||||||
|
|||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* KiraTV Application Entry Point
|
* NodeCast TV Application Entry Point
|
||||||
*/
|
*/
|
||||||
|
|
||||||
class App {
|
class App {
|
||||||
@@ -161,7 +161,7 @@ class App {
|
|||||||
const initialPage = hash && this.pages[hash] ? hash : 'home';
|
const initialPage = hash && this.pages[hash] ? hash : 'home';
|
||||||
this.navigateTo(initialPage, true); // true = replace history (don't add)
|
this.navigateTo(initialPage, true); // true = replace history (don't add)
|
||||||
|
|
||||||
console.log('KiraTV initialized');
|
console.log('NodeCast TV initialized');
|
||||||
}
|
}
|
||||||
|
|
||||||
async checkAuth() {
|
async checkAuth() {
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ class ChannelList {
|
|||||||
*/
|
*/
|
||||||
loadCollapsedState() {
|
loadCollapsedState() {
|
||||||
try {
|
try {
|
||||||
const saved = localStorage.getItem('kiratv_collapsed_groups');
|
const saved = localStorage.getItem('nodecast_tv_collapsed_groups');
|
||||||
if (saved) {
|
if (saved) {
|
||||||
this.collapsedGroups = new Set(JSON.parse(saved));
|
this.collapsedGroups = new Set(JSON.parse(saved));
|
||||||
this._hasCollapsedState = true;
|
this._hasCollapsedState = true;
|
||||||
@@ -64,7 +64,7 @@ class ChannelList {
|
|||||||
*/
|
*/
|
||||||
saveCollapsedState() {
|
saveCollapsedState() {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem('kiratv_collapsed_groups', JSON.stringify([...this.collapsedGroups]));
|
localStorage.setItem('nodecast_tv_collapsed_groups', JSON.stringify([...this.collapsedGroups]));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error saving collapsed state:', err);
|
console.error('Error saving collapsed state:', err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,9 +28,6 @@ class VideoPlayer {
|
|||||||
this.isUsingProxy = false;
|
this.isUsingProxy = false;
|
||||||
this.currentUrl = null;
|
this.currentUrl = null;
|
||||||
this.settingsLoaded = false;
|
this.settingsLoaded = false;
|
||||||
this.switchInProgress = false;
|
|
||||||
this.queuedSwitch = null;
|
|
||||||
this.lastSwitchAt = 0;
|
|
||||||
|
|
||||||
// Settings - start with defaults, load from server async
|
// Settings - start with defaults, load from server async
|
||||||
this.settings = this.getDefaultSettings();
|
this.settings = this.getDefaultSettings();
|
||||||
@@ -56,7 +53,7 @@ class VideoPlayer {
|
|||||||
forceTranscode: false,
|
forceTranscode: false,
|
||||||
forceRemux: false,
|
forceRemux: false,
|
||||||
autoTranscode: true,
|
autoTranscode: true,
|
||||||
streamFormat: 'ts',
|
streamFormat: 'm3u8',
|
||||||
epgRefreshInterval: '24'
|
epgRefreshInterval: '24'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -74,7 +71,7 @@ class VideoPlayer {
|
|||||||
console.warn('[Player] Failed to load settings from server, using defaults:', err.message);
|
console.warn('[Player] Failed to load settings from server, using defaults:', err.message);
|
||||||
// Fall back to localStorage for backwards compatibility
|
// Fall back to localStorage for backwards compatibility
|
||||||
try {
|
try {
|
||||||
const saved = localStorage.getItem('kiratv_player_settings');
|
const saved = localStorage.getItem('nodecast_tv_player_settings');
|
||||||
if (saved) {
|
if (saved) {
|
||||||
this.settings = { ...this.getDefaultSettings(), ...JSON.parse(saved) };
|
this.settings = { ...this.getDefaultSettings(), ...JSON.parse(saved) };
|
||||||
console.log('[Player] Settings loaded from localStorage (fallback)');
|
console.log('[Player] Settings loaded from localStorage (fallback)');
|
||||||
@@ -96,7 +93,7 @@ class VideoPlayer {
|
|||||||
console.error('[Player] Error saving settings to server:', err);
|
console.error('[Player] Error saving settings to server:', err);
|
||||||
// Also save to localStorage as backup
|
// Also save to localStorage as backup
|
||||||
try {
|
try {
|
||||||
localStorage.setItem('kiratv_player_settings', JSON.stringify(this.settings));
|
localStorage.setItem('nodecast_tv_player_settings', JSON.stringify(this.settings));
|
||||||
} catch (localErr) {
|
} catch (localErr) {
|
||||||
console.error('[Player] Error saving to localStorage:', localErr);
|
console.error('[Player] Error saving to localStorage:', localErr);
|
||||||
}
|
}
|
||||||
@@ -816,58 +813,37 @@ class VideoPlayer {
|
|||||||
* Start a HLS transcode session
|
* Start a HLS transcode session
|
||||||
*/
|
*/
|
||||||
async startTranscodeSession(url, options = {}) {
|
async startTranscodeSession(url, options = {}) {
|
||||||
console.log('[Player] Starting HLS transcode session...', options);
|
|
||||||
let lastError = null;
|
|
||||||
|
|
||||||
// Retry a couple of times to ride out provider-side 5xx/rate-limit spikes.
|
|
||||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
||||||
try {
|
try {
|
||||||
|
console.log('[Player] Starting HLS transcode session...', options);
|
||||||
const res = await fetch('/api/transcode/session', {
|
const res = await fetch('/api/transcode/session', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ url, ...options })
|
body: JSON.stringify({ url, ...options })
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) throw new Error('Failed to start session');
|
||||||
throw new Error(`Failed to start session (${res.status})`);
|
|
||||||
}
|
|
||||||
const session = await res.json();
|
const session = await res.json();
|
||||||
this.currentSessionId = session.sessionId;
|
this.currentSessionId = session.sessionId;
|
||||||
return session.playlistUrl;
|
return session.playlistUrl;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
lastError = err;
|
console.error('[Player] Session start failed:', err);
|
||||||
console.warn(`[Player] Session start failed (attempt ${attempt}/3):`, err?.message || err);
|
// Fallback to direct transcode if session fails
|
||||||
if (attempt < 3) {
|
return `/api/transcode?url=${encodeURIComponent(url)}`;
|
||||||
await new Promise(resolve => setTimeout(resolve, 900));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Avoid fallback to /api/transcode direct stream: it tends to overlap sessions
|
|
||||||
// and breaks providers with strict single-connection limits.
|
|
||||||
throw lastError || new Error('Failed to start transcode session');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stop and cleanup current transcode session
|
* Stop and cleanup current transcode session
|
||||||
*/
|
*/
|
||||||
async stopTranscodeSession() {
|
async stopTranscodeSession() {
|
||||||
if (!this.currentSessionId) return;
|
if (this.currentSessionId) {
|
||||||
|
console.log('[Player] Stopping transcode session:', this.currentSessionId);
|
||||||
const sessionId = this.currentSessionId;
|
|
||||||
this.currentSessionId = null;
|
|
||||||
console.log('[Player] Stopping transcode session:', sessionId);
|
|
||||||
try {
|
try {
|
||||||
const controller = new AbortController();
|
// Fire and forget cleanup
|
||||||
const timer = setTimeout(() => controller.abort(), 1500);
|
fetch(`/api/transcode/${this.currentSessionId}`, { method: 'DELETE' });
|
||||||
await fetch(`/api/transcode/${sessionId}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
signal: controller.signal
|
|
||||||
});
|
|
||||||
clearTimeout(timer);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err?.name !== 'AbortError') {
|
|
||||||
console.error('Failed to stop session:', err);
|
console.error('Failed to stop session:', err);
|
||||||
}
|
}
|
||||||
|
this.currentSessionId = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -875,26 +851,14 @@ class VideoPlayer {
|
|||||||
* Play a channel
|
* Play a channel
|
||||||
*/
|
*/
|
||||||
async play(channel, streamUrl) {
|
async play(channel, streamUrl) {
|
||||||
// Avoid overlapping channel changes; keep only the latest zap request.
|
|
||||||
if (this.switchInProgress) {
|
|
||||||
this.queuedSwitch = { channel, streamUrl };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.switchInProgress = true;
|
|
||||||
this.currentChannel = channel;
|
this.currentChannel = channel;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Stop any WatchPage playback (movies/series) before starting Live TV
|
// Stop any WatchPage playback (movies/series) before starting Live TV
|
||||||
window.app?.pages?.watch?.stop?.();
|
window.app?.pages?.watch?.stop?.();
|
||||||
|
|
||||||
// Stop current playback (sends DELETE to worker → FFmpeg dies → Dispatcharr frees the slot)
|
// Stop current playback
|
||||||
await this.stop();
|
this.stop();
|
||||||
|
|
||||||
// Give Dispatcharr enough time to release the stream slot before probing the new channel.
|
|
||||||
// 2500ms covers most providers; retries in the probe endpoint handle the edge cases.
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 2500));
|
|
||||||
|
|
||||||
this.lastSwitchAt = Date.now();
|
|
||||||
this.updateTranscodeStatus('hidden');
|
this.updateTranscodeStatus('hidden');
|
||||||
|
|
||||||
// Hide "select a channel" overlay
|
// Hide "select a channel" overlay
|
||||||
@@ -968,18 +932,15 @@ class VideoPlayer {
|
|||||||
window.dispatchEvent(new CustomEvent('channelChanged', { detail: channel }));
|
window.dispatchEvent(new CustomEvent('channelChanged', { detail: channel }));
|
||||||
return;
|
return;
|
||||||
} else if (info.needsRemux) {
|
} else if (info.needsRemux) {
|
||||||
// Algunos streams TS fallan en remux directo (ffmpeg mp4 mux errors).
|
// Raw .ts container - use remux
|
||||||
// En ese caso es mas estable usar sesion HLS con video copy.
|
console.log('[Player] Auto: Using remux (.ts container)');
|
||||||
console.log('[Player] Auto: Using HLS transcode session (copy for remux-needed stream)');
|
this.updateTranscodeStatus('remuxing', 'Remux (Auto)');
|
||||||
this.updateTranscodeStatus('transcoding', 'Transcoding (Auto)');
|
const remuxUrl = `/api/remux?url=${encodeURIComponent(streamUrl)}`;
|
||||||
const playlistUrl = await this.startTranscodeSession(streamUrl, {
|
this.currentUrl = remuxUrl;
|
||||||
videoMode: 'copy',
|
this.video.src = remuxUrl;
|
||||||
videoCodec: info.video,
|
this.video.play().catch(e => {
|
||||||
audioCodec: info.audio,
|
if (e.name !== 'AbortError') console.log('[Player] Autoplay prevented:', e);
|
||||||
audioChannels: info.audioChannels
|
|
||||||
});
|
});
|
||||||
this.currentUrl = playlistUrl;
|
|
||||||
this.playHls(playlistUrl);
|
|
||||||
this.updateNowPlaying(channel);
|
this.updateNowPlaying(channel);
|
||||||
this.showNowPlayingOverlay();
|
this.showNowPlayingOverlay();
|
||||||
this.fetchEpgData(channel);
|
this.fetchEpgData(channel);
|
||||||
@@ -1222,13 +1183,6 @@ class VideoPlayer {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error playing channel:', err);
|
console.error('Error playing channel:', err);
|
||||||
this.showError('Failed to play channel');
|
this.showError('Failed to play channel');
|
||||||
} finally {
|
|
||||||
this.switchInProgress = false;
|
|
||||||
if (this.queuedSwitch) {
|
|
||||||
const next = this.queuedSwitch;
|
|
||||||
this.queuedSwitch = null;
|
|
||||||
this.play(next.channel, next.streamUrl);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1433,9 +1387,9 @@ class VideoPlayer {
|
|||||||
/**
|
/**
|
||||||
* Stop playback
|
* Stop playback
|
||||||
*/
|
*/
|
||||||
async stop() {
|
stop() {
|
||||||
// Stop any running transcode session first
|
// Stop any running transcode session first
|
||||||
await this.stopTranscodeSession();
|
this.stopTranscodeSession();
|
||||||
|
|
||||||
if (this.hls) {
|
if (this.hls) {
|
||||||
this.hls.destroy();
|
this.hls.destroy();
|
||||||
|
|||||||
+25
-251
@@ -79,11 +79,6 @@ class WatchPage {
|
|||||||
this.isFavorite = false;
|
this.isFavorite = false;
|
||||||
this.returnPage = null;
|
this.returnPage = null;
|
||||||
this.captionsMenuOpen = false;
|
this.captionsMenuOpen = false;
|
||||||
this.sourceStreamUrl = null;
|
|
||||||
this.knownDurationSec = 0;
|
|
||||||
this.playbackBaseOffsetSec = 0;
|
|
||||||
this.isSessionMode = false;
|
|
||||||
this._heartbeatTimer = null;
|
|
||||||
|
|
||||||
// Overlay timer
|
// Overlay timer
|
||||||
this.overlayTimeout = null;
|
this.overlayTimeout = null;
|
||||||
@@ -243,20 +238,6 @@ class WatchPage {
|
|||||||
this.scrollHint?.classList.remove('hidden');
|
this.scrollHint?.classList.remove('hidden');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// On page close/refresh: stop heartbeat so the 3-min idle timeout kicks in fast
|
|
||||||
window.addEventListener('beforeunload', () => {
|
|
||||||
this._stopHeartbeat();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Also stop session when the page becomes hidden (tab switch, minimize)
|
|
||||||
document.addEventListener('visibilitychange', () => {
|
|
||||||
if (document.hidden) {
|
|
||||||
this._stopHeartbeat();
|
|
||||||
} else if (this.currentSessionId) {
|
|
||||||
this._startHeartbeat(this.currentSessionId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -272,9 +253,6 @@ class WatchPage {
|
|||||||
this.currentEpisode = content.currentEpisode || null;
|
this.currentEpisode = content.currentEpisode || null;
|
||||||
this.resumeTime = content.resumeTime || 0;
|
this.resumeTime = content.resumeTime || 0;
|
||||||
this.containerExtension = content.containerExtension || 'mp4';
|
this.containerExtension = content.containerExtension || 'mp4';
|
||||||
this.knownDurationSec = this.parseDurationSeconds(content.durationSec || content.duration || content.runtime || '');
|
|
||||||
this.playbackBaseOffsetSec = 0;
|
|
||||||
this.isSessionMode = false;
|
|
||||||
this.returnPage = content.type === 'movie' ? 'movies' : 'series';
|
this.returnPage = content.type === 'movie' ? 'movies' : 'series';
|
||||||
|
|
||||||
// Stop any Live TV playback before starting movie/series
|
// Stop any Live TV playback before starting movie/series
|
||||||
@@ -293,14 +271,6 @@ class WatchPage {
|
|||||||
// Update title bar
|
// Update title bar
|
||||||
this.titleEl.textContent = content.title || '';
|
this.titleEl.textContent = content.title || '';
|
||||||
this.subtitleEl.textContent = content.subtitle || '';
|
this.subtitleEl.textContent = content.subtitle || '';
|
||||||
if (this.timeTotal) {
|
|
||||||
this.timeTotal.textContent = this.knownDurationSec > 0 ? this.formatTime(this.knownDurationSec) : '0:00';
|
|
||||||
}
|
|
||||||
|
|
||||||
// If duration is missing, fetch it from vod_info in the background
|
|
||||||
if (this.knownDurationSec === 0 && content.sourceId && (content.vodId || content.id)) {
|
|
||||||
this._fetchVodDuration(content.sourceId, content.vodId || content.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load video
|
// Load video
|
||||||
await this.loadVideo(streamUrl);
|
await this.loadVideo(streamUrl);
|
||||||
@@ -338,7 +308,7 @@ class WatchPage {
|
|||||||
const indicator = document.getElementById('now-playing-indicator');
|
const indicator = document.getElementById('now-playing-indicator');
|
||||||
const textEl = document.getElementById('now-playing-text');
|
const textEl = document.getElementById('now-playing-text');
|
||||||
if (indicator && textEl) {
|
if (indicator && textEl) {
|
||||||
textEl.textContent = title || 'Reproduciendo';
|
textEl.textContent = title || 'Now Playing';
|
||||||
indicator.classList.remove('hidden');
|
indicator.classList.remove('hidden');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -371,22 +341,10 @@ class WatchPage {
|
|||||||
if (!res.ok) throw new Error('Failed to start session');
|
if (!res.ok) throw new Error('Failed to start session');
|
||||||
const session = await res.json();
|
const session = await res.json();
|
||||||
this.currentSessionId = session.sessionId;
|
this.currentSessionId = session.sessionId;
|
||||||
this.isSessionMode = true;
|
|
||||||
this._startHeartbeat(session.sessionId);
|
|
||||||
|
|
||||||
// Use server-probed duration if we don't already have one
|
|
||||||
if (session.durationSec > 0 && this.knownDurationSec === 0) {
|
|
||||||
this.knownDurationSec = session.durationSec;
|
|
||||||
console.log(`[WatchPage] Duration from FFprobe: ${session.durationSec}s`);
|
|
||||||
if (this.timeTotal) {
|
|
||||||
this.timeTotal.textContent = this.formatTime(session.durationSec);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return session.playlistUrl;
|
return session.playlistUrl;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[WatchPage] Session start failed:', err);
|
console.error('[WatchPage] Session start failed:', err);
|
||||||
this.isSessionMode = true; // Still session-like for duration purposes
|
// Fallback to direct transcode if session fails
|
||||||
return `/api/transcode?url=${encodeURIComponent(url)}`;
|
return `/api/transcode?url=${encodeURIComponent(url)}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -395,21 +353,15 @@ class WatchPage {
|
|||||||
* Stop and cleanup current transcode session
|
* Stop and cleanup current transcode session
|
||||||
*/
|
*/
|
||||||
async stopTranscodeSession() {
|
async stopTranscodeSession() {
|
||||||
this._stopHeartbeat();
|
|
||||||
if (this.currentSessionId) {
|
if (this.currentSessionId) {
|
||||||
console.log('[WatchPage] Stopping transcode session:', this.currentSessionId);
|
console.log('[WatchPage] Stopping transcode session:', this.currentSessionId);
|
||||||
const sid = this.currentSessionId;
|
|
||||||
this.currentSessionId = null;
|
|
||||||
try {
|
try {
|
||||||
// sendBeacon works even when the page is being unloaded
|
// Fire and forget cleanup
|
||||||
if (navigator.sendBeacon) {
|
fetch(`/api/transcode/${this.currentSessionId}`, { method: 'DELETE' });
|
||||||
navigator.sendBeacon(`/api/transcode/${sid}/stop`, '{}');
|
|
||||||
}
|
|
||||||
// Also DELETE (for normal navigation)
|
|
||||||
fetch(`/api/transcode/${sid}`, { method: 'DELETE' }).catch(() => {});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to stop session:', err);
|
console.error('Failed to stop session:', err);
|
||||||
}
|
}
|
||||||
|
this.currentSessionId = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -460,7 +412,6 @@ class WatchPage {
|
|||||||
async loadVideo(url) {
|
async loadVideo(url) {
|
||||||
// Store the URL for copy functionality
|
// Store the URL for copy functionality
|
||||||
this.currentUrl = url;
|
this.currentUrl = url;
|
||||||
this.sourceStreamUrl = url;
|
|
||||||
|
|
||||||
// Stop any existing playback
|
// Stop any existing playback
|
||||||
this.stop();
|
this.stop();
|
||||||
@@ -468,23 +419,6 @@ class WatchPage {
|
|||||||
// Show loading spinner
|
// Show loading spinner
|
||||||
this.showLoading();
|
this.showLoading();
|
||||||
|
|
||||||
if (typeof window.kiratvPlayVodInNativePlayer === 'function' && window.kiratvDesktop?.isDesktopApp) {
|
|
||||||
this.hideLoading();
|
|
||||||
const absUrl = window.kiratvAbsolutePlaybackUrl(url);
|
|
||||||
try {
|
|
||||||
const result = await window.kiratvPlayVodInNativePlayer(absUrl);
|
|
||||||
if (!result?.ok) {
|
|
||||||
console.error('[KiraTV Desktop] VOD mpv:', result?.error);
|
|
||||||
this.updateTranscodeStatus('hidden');
|
|
||||||
} else {
|
|
||||||
this.updateTranscodeStatus('direct', 'mpv nativo');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[KiraTV Desktop] VOD', e);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get settings for proxy/transcode
|
// Get settings for proxy/transcode
|
||||||
let settings = {};
|
let settings = {};
|
||||||
try {
|
try {
|
||||||
@@ -528,7 +462,6 @@ class WatchPage {
|
|||||||
audioCodec: info.audio,
|
audioCodec: info.audio,
|
||||||
audioChannels: info.audioChannels
|
audioChannels: info.audioChannels
|
||||||
});
|
});
|
||||||
this.playbackBaseOffsetSec = this.resumeTime || 0;
|
|
||||||
this.playHls(playlistUrl);
|
this.playHls(playlistUrl);
|
||||||
this.setVolumeFromStorage();
|
this.setVolumeFromStorage();
|
||||||
return;
|
return;
|
||||||
@@ -548,30 +481,8 @@ class WatchPage {
|
|||||||
// Compatible - fall through to normal playback
|
// Compatible - fall through to normal playback
|
||||||
console.log('[WatchPage] Auto: Using normal playback (compatible)');
|
console.log('[WatchPage] Auto: Using normal playback (compatible)');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[WatchPage] Probe failed:', err.message);
|
console.warn('[WatchPage] Probe failed, using normal playback:', err.message);
|
||||||
const vodLike =
|
// Continue with normal playback on probe failure
|
||||||
isDirectVideo ||
|
|
||||||
url.includes('/movie/') ||
|
|
||||||
url.includes('/series/');
|
|
||||||
if (vodLike) {
|
|
||||||
console.warn(
|
|
||||||
'[WatchPage] Auto: VOD-like URL after probe failure — HLS encode fallback (evita reproducir remux Jellyfin sin transcodificar)'
|
|
||||||
);
|
|
||||||
this.updateTranscodeStatus('transcoding', 'Transcoding (auto, probe fallback)');
|
|
||||||
try {
|
|
||||||
const playlistUrl = await this.startTranscodeSession(url, {
|
|
||||||
videoMode: 'encode',
|
|
||||||
seekOffset: this.resumeTime,
|
|
||||||
});
|
|
||||||
this.playbackBaseOffsetSec = this.resumeTime || 0;
|
|
||||||
this.playHls(playlistUrl);
|
|
||||||
this.setVolumeFromStorage();
|
|
||||||
return;
|
|
||||||
} catch (fallbackErr) {
|
|
||||||
console.error('[WatchPage] Encode fallback failed:', fallbackErr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Live u otro contenido: seguir reproducción directa habitual
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -585,7 +496,6 @@ class WatchPage {
|
|||||||
videoMode: 'encode',
|
videoMode: 'encode',
|
||||||
seekOffset: this.resumeTime
|
seekOffset: this.resumeTime
|
||||||
});
|
});
|
||||||
this.playbackBaseOffsetSec = this.resumeTime || 0;
|
|
||||||
this.playHls(playlistUrl);
|
this.playHls(playlistUrl);
|
||||||
this.setVolumeFromStorage();
|
this.setVolumeFromStorage();
|
||||||
return;
|
return;
|
||||||
@@ -609,7 +519,6 @@ class WatchPage {
|
|||||||
videoCodec,
|
videoCodec,
|
||||||
seekOffset: this.resumeTime
|
seekOffset: this.resumeTime
|
||||||
});
|
});
|
||||||
this.playbackBaseOffsetSec = this.resumeTime || 0;
|
|
||||||
this.playHls(playlistUrl);
|
this.playHls(playlistUrl);
|
||||||
this.setVolumeFromStorage();
|
this.setVolumeFromStorage();
|
||||||
return;
|
return;
|
||||||
@@ -621,7 +530,6 @@ class WatchPage {
|
|||||||
this.updateTranscodeStatus('remuxing', 'Remux (Force)');
|
this.updateTranscodeStatus('remuxing', 'Remux (Force)');
|
||||||
const finalUrl = `/api/remux?url=${encodeURIComponent(url)}`;
|
const finalUrl = `/api/remux?url=${encodeURIComponent(url)}`;
|
||||||
this.video.src = finalUrl;
|
this.video.src = finalUrl;
|
||||||
this.playbackBaseOffsetSec = 0;
|
|
||||||
this.video.play().catch(e => {
|
this.video.play().catch(e => {
|
||||||
if (e.name !== 'AbortError') console.error('[WatchPage] Autoplay error:', e);
|
if (e.name !== 'AbortError') console.error('[WatchPage] Autoplay error:', e);
|
||||||
});
|
});
|
||||||
@@ -639,12 +547,10 @@ class WatchPage {
|
|||||||
// Use HLS.js for HLS streams
|
// Use HLS.js for HLS streams
|
||||||
if (looksLikeHls && Hls.isSupported()) {
|
if (looksLikeHls && Hls.isSupported()) {
|
||||||
this.updateTranscodeStatus('direct', 'Direct HLS');
|
this.updateTranscodeStatus('direct', 'Direct HLS');
|
||||||
this.playbackBaseOffsetSec = 0;
|
|
||||||
this.playHls(finalUrl);
|
this.playHls(finalUrl);
|
||||||
} else {
|
} else {
|
||||||
// Direct playback for mp4/mkv/avi
|
// Direct playback for mp4/mkv/avi
|
||||||
this.updateTranscodeStatus('direct', 'Direct Play');
|
this.updateTranscodeStatus('direct', 'Direct Play');
|
||||||
this.playbackBaseOffsetSec = 0;
|
|
||||||
this.video.src = finalUrl;
|
this.video.src = finalUrl;
|
||||||
this.video.play().catch(e => {
|
this.video.play().catch(e => {
|
||||||
if (e.name !== 'AbortError') console.error('[WatchPage] Autoplay error:', e);
|
if (e.name !== 'AbortError') console.error('[WatchPage] Autoplay error:', e);
|
||||||
@@ -705,7 +611,7 @@ class WatchPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setVolumeFromStorage() {
|
setVolumeFromStorage() {
|
||||||
const savedVolume = localStorage.getItem('kiratv-volume') || '80';
|
const savedVolume = localStorage.getItem('nodecast-volume') || '80';
|
||||||
this.video.volume = parseInt(savedVolume) / 100;
|
this.video.volume = parseInt(savedVolume) / 100;
|
||||||
if (this.volumeSlider) this.volumeSlider.value = savedVolume;
|
if (this.volumeSlider) this.volumeSlider.value = savedVolume;
|
||||||
}
|
}
|
||||||
@@ -715,13 +621,6 @@ class WatchPage {
|
|||||||
this.stopHistoryTracking();
|
this.stopHistoryTracking();
|
||||||
this.saveProgress();
|
this.saveProgress();
|
||||||
|
|
||||||
if (typeof window !== 'undefined' && window.kiratvDesktop?.stopExternalMpv) {
|
|
||||||
window.kiratvDesktop.stopExternalMpv().catch(() => {});
|
|
||||||
}
|
|
||||||
if (typeof window.kiratvDesktopClearEmbeddedChrome === 'function') {
|
|
||||||
window.kiratvDesktopClearEmbeddedChrome();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cleanup transcode session if exists
|
// Cleanup transcode session if exists
|
||||||
this.stopTranscodeSession();
|
this.stopTranscodeSession();
|
||||||
this.updateTranscodeStatus('hidden');
|
this.updateTranscodeStatus('hidden');
|
||||||
@@ -738,13 +637,9 @@ class WatchPage {
|
|||||||
}
|
}
|
||||||
if (this.video) {
|
if (this.video) {
|
||||||
this.video.pause();
|
this.video.pause();
|
||||||
// En modo escritorio el vídeo va a mpv: limpiar src dispararía un error
|
|
||||||
// inofensivo pero confuso (MEDIA_ELEMENT_ERROR: Empty src attribute).
|
|
||||||
if (!window.kiratvDesktop?.isDesktopApp) {
|
|
||||||
this.video.src = '';
|
this.video.src = '';
|
||||||
this.video.load();
|
this.video.load();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
this.hideNowPlaying();
|
this.hideNowPlaying();
|
||||||
}
|
}
|
||||||
@@ -760,38 +655,15 @@ class WatchPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
skip(seconds) {
|
skip(seconds) {
|
||||||
if (!this.video) return;
|
if (this.video) {
|
||||||
const totalDuration = this.getTotalDuration();
|
this.video.currentTime = Math.max(0, Math.min(this.video.currentTime + seconds, this.video.duration || 0));
|
||||||
const currentAbsolute = this.getAbsoluteCurrentTime();
|
|
||||||
if (totalDuration > 0) {
|
|
||||||
const target = Math.max(0, Math.min(currentAbsolute + seconds, totalDuration));
|
|
||||||
this.seekToAbsolute(target);
|
|
||||||
} else {
|
|
||||||
this.video.currentTime = Math.max(0, this.video.currentTime + seconds);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
seek(percent) {
|
seek(percent) {
|
||||||
const totalDuration = this.getTotalDuration();
|
if (this.video && this.video.duration) {
|
||||||
if (!this.video || !totalDuration) return;
|
this.video.currentTime = (percent / 100) * this.video.duration;
|
||||||
const target = (percent / 100) * totalDuration;
|
|
||||||
this.seekToAbsolute(target);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
seekToAbsolute(targetSec) {
|
|
||||||
if (!this.video) return;
|
|
||||||
|
|
||||||
if (this.isSessionMode && this.sourceStreamUrl) {
|
|
||||||
// Session-based HLS: the playlist only covers a window from the last seekOffset.
|
|
||||||
// Re-create the session from the new target position.
|
|
||||||
const offset = Math.max(0, Math.floor(targetSec));
|
|
||||||
this.playbackBaseOffsetSec = offset;
|
|
||||||
this.resumeTime = offset; // startTranscodeSession reads this for seekOffset
|
|
||||||
this.loadVideo(this.sourceStreamUrl);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.video.currentTime = Math.max(0, targetSec);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleMute() {
|
toggleMute() {
|
||||||
@@ -805,7 +677,7 @@ class WatchPage {
|
|||||||
if (this.video) {
|
if (this.video) {
|
||||||
this.video.volume = value / 100;
|
this.video.volume = value / 100;
|
||||||
this.video.muted = false;
|
this.video.muted = false;
|
||||||
localStorage.setItem('kiratv-volume', value);
|
localStorage.setItem('nodecast-volume', value);
|
||||||
this.updateVolumeUI();
|
this.updateVolumeUI();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -896,22 +768,18 @@ class WatchPage {
|
|||||||
// === UI Updates ===
|
// === UI Updates ===
|
||||||
|
|
||||||
updateProgress() {
|
updateProgress() {
|
||||||
if (!this.video) return;
|
if (!this.video || !this.video.duration) return;
|
||||||
|
|
||||||
const totalDuration = this.getTotalDuration();
|
const percent = (this.video.currentTime / this.video.duration) * 100;
|
||||||
if (!totalDuration) return;
|
this.progressSlider.value = percent;
|
||||||
|
this.timeCurrent.textContent = this.formatTime(this.video.currentTime);
|
||||||
const currentAbs = this.getAbsoluteCurrentTime();
|
|
||||||
const percent = (currentAbs / totalDuration) * 100;
|
|
||||||
this.progressSlider.value = Math.max(0, Math.min(100, percent));
|
|
||||||
this.timeCurrent.textContent = this.formatTime(currentAbs);
|
|
||||||
|
|
||||||
// Show "Up Next" panel early for series (like streaming services do during credits)
|
// Show "Up Next" panel early for series (like streaming services do during credits)
|
||||||
// Only show if auto-play next episode is enabled
|
// Only show if auto-play next episode is enabled
|
||||||
const autoPlayEnabled = this.app?.player?.settings?.autoPlayNextEpisode;
|
const autoPlayEnabled = this.app?.player?.settings?.autoPlayNextEpisode;
|
||||||
if (autoPlayEnabled && this.contentType === 'series' && this.seriesInfo && !this.nextEpisodeShowing && !this.nextEpisodeDismissed) {
|
if (autoPlayEnabled && this.contentType === 'series' && this.seriesInfo && !this.nextEpisodeShowing && !this.nextEpisodeDismissed) {
|
||||||
const duration = totalDuration;
|
const duration = this.video.duration;
|
||||||
const currentTime = currentAbs;
|
const currentTime = this.video.currentTime;
|
||||||
|
|
||||||
// Only proceed if we have reliable duration data
|
// Only proceed if we have reliable duration data
|
||||||
if (isFinite(duration) && duration >= 180 && currentTime >= 120) {
|
if (isFinite(duration) && duration >= 180 && currentTime >= 120) {
|
||||||
@@ -939,10 +807,8 @@ class WatchPage {
|
|||||||
this.updateQualityBadge();
|
this.updateQualityBadge();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle resumption (only for direct video, NOT session mode).
|
// Handle resumption
|
||||||
// In session mode FFmpeg already starts from the seekOffset, so
|
if (this.resumeTime > 0 && this.video) {
|
||||||
// video.currentTime=0 already represents the correct absolute position.
|
|
||||||
if (!this.isSessionMode && this.resumeTime > 0 && this.video) {
|
|
||||||
const duration = this.video.duration;
|
const duration = this.video.duration;
|
||||||
// Only resume if not near the end (95%)
|
// Only resume if not near the end (95%)
|
||||||
if (!duration || this.resumeTime < duration * 0.95) {
|
if (!duration || this.resumeTime < duration * 0.95) {
|
||||||
@@ -950,14 +816,6 @@ class WatchPage {
|
|||||||
this.video.currentTime = this.resumeTime;
|
this.video.currentTime = this.resumeTime;
|
||||||
}
|
}
|
||||||
this.resumeTime = 0; // Reset after use
|
this.resumeTime = 0; // Reset after use
|
||||||
} else if (this.isSessionMode) {
|
|
||||||
this.resumeTime = 0; // Clear so it's not applied again
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update total duration label using known VOD runtime when browser reports Infinity/NaN
|
|
||||||
const totalDuration = this.getTotalDuration();
|
|
||||||
if (this.timeTotal && totalDuration > 0) {
|
|
||||||
this.timeTotal.textContent = this.formatTime(totalDuration);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -994,12 +852,12 @@ class WatchPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onError(e) {
|
onError(e) {
|
||||||
|
// Only log actual fatal errors, not benign stream recovery events
|
||||||
const error = this.video?.error;
|
const error = this.video?.error;
|
||||||
if (!error || !error.code) return;
|
if (error && error.code) {
|
||||||
// Ignorar error por src vacío (ocurre al limpiar el elemento tras parar)
|
|
||||||
if (error.code === 4 && (!this.video.src || this.video.src === window.location.href)) return;
|
|
||||||
console.error('[WatchPage] Video error:', error.code, error.message);
|
console.error('[WatchPage] Video error:', error.code, error.message);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
updateVolumeUI() {
|
updateVolumeUI() {
|
||||||
const isMuted = this.video?.muted || this.video?.volume === 0;
|
const isMuted = this.video?.muted || this.video?.volume === 0;
|
||||||
@@ -1018,90 +876,6 @@ class WatchPage {
|
|||||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
parseDurationSeconds(value) {
|
|
||||||
if (!value) return 0;
|
|
||||||
if (typeof value === 'number' && Number.isFinite(value)) return Math.max(0, Math.floor(value));
|
|
||||||
const str = String(value).trim();
|
|
||||||
if (!str) return 0;
|
|
||||||
|
|
||||||
// HH:MM:SS or MM:SS (allow any number of digits in the first segment)
|
|
||||||
if (/^\d+:\d{1,2}(:\d{1,2})?$/.test(str)) {
|
|
||||||
const parts = str.split(':').map(n => parseInt(n, 10));
|
|
||||||
if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
|
||||||
if (parts.length === 2) return parts[0] * 60 + parts[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Plain seconds string
|
|
||||||
const asNum = parseInt(str, 10);
|
|
||||||
return Number.isFinite(asNum) ? Math.max(0, asNum) : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
_startHeartbeat(sessionId) {
|
|
||||||
this._stopHeartbeat();
|
|
||||||
// Ping every 90 seconds — well within the 3-minute idle timeout on the worker
|
|
||||||
this._heartbeatTimer = setInterval(async () => {
|
|
||||||
if (!sessionId) return;
|
|
||||||
try {
|
|
||||||
await fetch(`/api/transcode/${sessionId}/heartbeat`, { method: 'POST' });
|
|
||||||
} catch (_) { /* ignore */ }
|
|
||||||
}, 90_000);
|
|
||||||
}
|
|
||||||
|
|
||||||
_stopHeartbeat() {
|
|
||||||
if (this._heartbeatTimer) {
|
|
||||||
clearInterval(this._heartbeatTimer);
|
|
||||||
this._heartbeatTimer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch VOD/episode duration from the Xtream vod_info endpoint in the background.
|
|
||||||
* Updates knownDurationSec and the UI time label once the response arrives.
|
|
||||||
*/
|
|
||||||
async _fetchVodDuration(sourceId, vodId) {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/proxy/xtream/${sourceId}/vod_info?vod_id=${vodId}`);
|
|
||||||
if (!res.ok) return;
|
|
||||||
const data = await res.json();
|
|
||||||
const raw = data?.info?.duration_secs
|
|
||||||
|| data?.info?.duration
|
|
||||||
|| data?.movie_data?.duration_secs
|
|
||||||
|| data?.info?.runtime;
|
|
||||||
const secs = this.parseDurationSeconds(raw);
|
|
||||||
if (secs > 0 && secs > this.knownDurationSec) {
|
|
||||||
console.log(`[WatchPage] VOD duration from vod_info: ${secs}s`);
|
|
||||||
this.knownDurationSec = secs;
|
|
||||||
if (this.timeTotal) {
|
|
||||||
this.timeTotal.textContent = this.formatTime(secs);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Non-critical — silently ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getTotalDuration() {
|
|
||||||
// In session mode (HLS generated by our worker), video.duration only
|
|
||||||
// reflects the segments generated so far, NOT the full movie length.
|
|
||||||
// Always prefer the known VOD duration in this case.
|
|
||||||
if (this.isSessionMode && this.knownDurationSec > 0) {
|
|
||||||
return this.knownDurationSec;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mediaDuration = this.video?.duration;
|
|
||||||
if (Number.isFinite(mediaDuration) && mediaDuration > 0) {
|
|
||||||
const total = mediaDuration + (this.playbackBaseOffsetSec || 0);
|
|
||||||
// Even outside session mode, pick whichever is larger
|
|
||||||
return (this.knownDurationSec > total) ? this.knownDurationSec : total;
|
|
||||||
}
|
|
||||||
return this.knownDurationSec || 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
getAbsoluteCurrentTime() {
|
|
||||||
if (!this.video) return 0;
|
|
||||||
return (this.playbackBaseOffsetSec || 0) + (this.video.currentTime || 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Loading Spinner ===
|
// === Loading Spinner ===
|
||||||
|
|
||||||
showLoading() {
|
showLoading() {
|
||||||
@@ -1643,7 +1417,7 @@ class WatchPage {
|
|||||||
try {
|
try {
|
||||||
const data = {
|
const data = {
|
||||||
title: this.content.title || 'Unknown Title',
|
title: this.content.title || 'Unknown Title',
|
||||||
subtitle: this.content.subtitle || (this.content.type === 'movie' ? 'Película' : 'Serie'),
|
subtitle: this.content.subtitle || (this.content.type === 'movie' ? 'Movie' : 'Series'),
|
||||||
poster: this.content.poster,
|
poster: this.content.poster,
|
||||||
sourceId: this.content.sourceId,
|
sourceId: this.content.sourceId,
|
||||||
containerExtension: this.containerExtension,
|
containerExtension: this.containerExtension,
|
||||||
|
|||||||
+5
-5
@@ -1,10 +1,10 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="es">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Iniciar sesión — KiraTV</title>
|
<title>Login - NodeCast TV</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
<link rel="stylesheet" href="css/main.css">
|
<link rel="stylesheet" href="css/main.css">
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
@@ -171,13 +171,13 @@
|
|||||||
<path
|
<path
|
||||||
d="M169.71,87.87c-4.77,0-9.1,1.88-12.31,4.92L35.3,22.3c.35-1.4.56-2.86.56-4.37C35.86,8.03,27.83,0,17.93,0S0,8.03,0,17.93c0,8.44,5.84,15.5,13.69,17.4v140c-7.71,2.01-13.41,8.99-13.41,17.33,0,9.9,8.03,17.93,17.93,17.93s17.93-8.03,17.93-17.93c0-1.56-.22-3.07-.6-4.51l121.21-69.98c3.26,3.42,7.86,5.55,12.95,5.55,9.9,0,17.93-8.03,17.93-17.93s-8.03-17.93-17.93-17.93ZM152.78,99.92c-.14.41-.27.83-.38,1.25h-51.47l-44.18-24.72L30.4,30.8c.33-.32.64-.65.94-.99l121.45,70.12ZM22.17,175.2V35.33c.23-.05.45-.11.67-.17l26.35,45.65-.34,50.04-25.74,44.59c-.31-.09-.63-.17-.95-.24ZM31.52,180.69c-.27-.3-.55-.6-.84-.89l25.48-44.13,44.64-25.78h51.47c.07.32.15.63.24.94L31.52,180.69Z" />
|
d="M169.71,87.87c-4.77,0-9.1,1.88-12.31,4.92L35.3,22.3c.35-1.4.56-2.86.56-4.37C35.86,8.03,27.83,0,17.93,0S0,8.03,0,17.93c0,8.44,5.84,15.5,13.69,17.4v140c-7.71,2.01-13.41,8.99-13.41,17.33,0,9.9,8.03,17.93,17.93,17.93s17.93-8.03,17.93-17.93c0-1.56-.22-3.07-.6-4.51l121.21-69.98c3.26,3.42,7.86,5.55,12.95,5.55,9.9,0,17.93-8.03,17.93-17.93s-8.03-17.93-17.93-17.93ZM152.78,99.92c-.14.41-.27.83-.38,1.25h-51.47l-44.18-24.72L30.4,30.8c.33-.32.64-.65.94-.99l121.45,70.12ZM22.17,175.2V35.33c.23-.05.45-.11.67-.17l26.35,45.65-.34,50.04-25.74,44.59c-.31-.09-.63-.17-.95-.24ZM31.52,180.69c-.27-.3-.55-.6-.84-.89l25.48-44.13,44.64-25.78h51.47c.07.32.15.63.24.94L31.52,180.69Z" />
|
||||||
</svg>
|
</svg>
|
||||||
<h1>KiraTV</h1>
|
<h1>nodecast-tv</h1>
|
||||||
<p id="login-subtitle">Inicia sesión para continuar</p>
|
<p id="login-subtitle">Sign in to continue</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="error-message" id="error-message"></div>
|
<div class="error-message" id="error-message"></div>
|
||||||
<div class="setup-message" id="setup-message">
|
<div class="setup-message" id="setup-message">
|
||||||
Bienvenido. Crea tu cuenta de administrador para empezar.
|
Welcome! Please create your admin account to get started.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form id="login-form">
|
<form id="login-form">
|
||||||
|
|||||||
+2
-94
@@ -27,14 +27,7 @@ app.use(session({
|
|||||||
app.use(passport.initialize());
|
app.use(passport.initialize());
|
||||||
app.use(passport.session());
|
app.use(passport.session());
|
||||||
|
|
||||||
// Serve static files with no-cache for JS/CSS so browser always loads the latest version
|
app.use(express.static(path.join(__dirname, '..', 'public')));
|
||||||
app.use(express.static(path.join(__dirname, '..', 'public'), {
|
|
||||||
setHeaders(res, filePath) {
|
|
||||||
if (filePath.endsWith('.js') || filePath.endsWith('.css')) {
|
|
||||||
res.setHeader('Cache-Control', 'no-cache, must-revalidate');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
// FFMPEG Configuration (optional - for transcoding support)
|
// FFMPEG Configuration (optional - for transcoding support)
|
||||||
// Priority: 1. System FFmpeg (better Docker DNS support), 2. ffmpeg-static npm package
|
// Priority: 1. System FFmpeg (better Docker DNS support), 2. ffmpeg-static npm package
|
||||||
@@ -95,86 +88,6 @@ function findFFprobe() {
|
|||||||
app.locals.ffmpegPath = findFFmpeg();
|
app.locals.ffmpegPath = findFFmpeg();
|
||||||
app.locals.ffprobePath = findFFprobe();
|
app.locals.ffprobePath = findFFprobe();
|
||||||
|
|
||||||
// Dynamic services loader - collects exports from files in ./services
|
|
||||||
const fs = require('fs');
|
|
||||||
const services = {};
|
|
||||||
try {
|
|
||||||
const servicesDir = path.join(__dirname, 'services');
|
|
||||||
const serviceFiles = fs.readdirSync(servicesDir).filter(f => f.endsWith('.js'));
|
|
||||||
for (const file of serviceFiles) {
|
|
||||||
const name = file.replace(/\.js$/, '');
|
|
||||||
try {
|
|
||||||
services[name] = require(path.join(servicesDir, file));
|
|
||||||
} catch (e) {
|
|
||||||
console.warn(`Failed to load service ${file}:`, e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('No services directory found or failed to read services:', e.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Freeze services object to prevent plugins from mutating shared state
|
|
||||||
Object.freeze(services);
|
|
||||||
|
|
||||||
// Plugin loader: loads any .js file inside server/plugins and calls the
|
|
||||||
// exported function with (app, services).
|
|
||||||
// Supports both function exports and object exports with lifecycle hooks.
|
|
||||||
const loadedPlugins = [];
|
|
||||||
|
|
||||||
async function loadPlugins() {
|
|
||||||
try {
|
|
||||||
const pluginsDir = path.join(__dirname, 'plugins');
|
|
||||||
if (fs.existsSync(pluginsDir)) {
|
|
||||||
// Sort plugin files alphabetically for deterministic load order
|
|
||||||
const pluginFiles = fs.readdirSync(pluginsDir)
|
|
||||||
.filter(f => f.endsWith('.js'))
|
|
||||||
.sort();
|
|
||||||
|
|
||||||
for (const file of pluginFiles) {
|
|
||||||
const pluginPath = path.join(pluginsDir, file);
|
|
||||||
try {
|
|
||||||
const plugin = require(pluginPath);
|
|
||||||
|
|
||||||
// Support both function exports and object exports with lifecycle hooks
|
|
||||||
if (typeof plugin === 'function') {
|
|
||||||
// Direct function export (sync or async)
|
|
||||||
await plugin(app, services);
|
|
||||||
loadedPlugins.push({ name: file, plugin: null });
|
|
||||||
console.log(`✓ Loaded plugin: ${file}`);
|
|
||||||
} else if (plugin && typeof plugin.init === 'function') {
|
|
||||||
// Object export with init/shutdown lifecycle
|
|
||||||
await plugin.init(app, services);
|
|
||||||
loadedPlugins.push({ name: file, plugin });
|
|
||||||
console.log(`✓ Loaded plugin: ${file} (with lifecycle hooks)`);
|
|
||||||
} else {
|
|
||||||
console.warn(`⚠ Plugin ${file} does not export a function or object with init(), skipping.`);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`✗ Failed to load plugin ${file}:`, err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('Plugin loader failed:', err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Graceful shutdown handler for plugins with shutdown hooks
|
|
||||||
process.on('SIGTERM', async () => {
|
|
||||||
console.log('SIGTERM received, shutting down plugins...');
|
|
||||||
for (const { name, plugin } of loadedPlugins) {
|
|
||||||
if (plugin && typeof plugin.shutdown === 'function') {
|
|
||||||
try {
|
|
||||||
await plugin.shutdown();
|
|
||||||
console.log(`✓ Shutdown plugin: ${name}`);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`✗ Error shutting down plugin ${name}:`, err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
process.exit(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
// API Routes
|
// API Routes
|
||||||
app.use('/api/auth', require('./routes/auth'));
|
app.use('/api/auth', require('./routes/auth'));
|
||||||
app.use('/api/sources', require('./routes/sources'));
|
app.use('/api/sources', require('./routes/sources'));
|
||||||
@@ -206,12 +119,7 @@ app.use((err, req, res, next) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.listen(PORT, async () => {
|
app.listen(PORT, async () => {
|
||||||
console.log(`KiraTV server running on http://localhost:${PORT}`);
|
console.log(`NodeCast TV server running on http://localhost:${PORT}`);
|
||||||
|
|
||||||
// Load plugins
|
|
||||||
await loadPlugins().catch(err => {
|
|
||||||
console.error('Plugin initialization failed:', err);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Trigger background sync with delay to allow server to settle
|
// Trigger background sync with delay to allow server to settle
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
|
|||||||
@@ -1,163 +0,0 @@
|
|||||||
# nodecast-tv Plugin System
|
|
||||||
|
|
||||||
This directory allows you to extend the functionality of **nodecast-tv** without modifying the core source code. The server automatically detects and loads any `.js` file placed in this folder at startup.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## How It Works
|
|
||||||
|
|
||||||
The plugin loader in `server/index.js` scans this directory at startup and loads plugins in **alphabetical order** (sorted by filename). Each plugin file should export either:
|
|
||||||
- A function (sync or async) that will be called during initialization
|
|
||||||
- An object with `init()` and optionally `shutdown()` methods for lifecycle management
|
|
||||||
|
|
||||||
When the server starts, it calls your plugin's initialization code and passes:
|
|
||||||
- `app` - The Express application instance
|
|
||||||
- `services` - A **frozen** (read-only) object containing all internal services
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Plugin Patterns
|
|
||||||
|
|
||||||
### Pattern 1: Simple Function Export
|
|
||||||
|
|
||||||
The simplest plugin pattern - just export a function:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
/**
|
|
||||||
* @param {Object} app - The Express application instance
|
|
||||||
* @param {Object} services - Frozen object containing all internal services
|
|
||||||
*/
|
|
||||||
module.exports = function(app, services) {
|
|
||||||
// Register routes, middleware, etc.
|
|
||||||
app.get('/api/my-route', (req, res) => {
|
|
||||||
res.json({ message: 'Hello from plugin!' });
|
|
||||||
});
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### Pattern 2: Async Function Export
|
|
||||||
|
|
||||||
For plugins that need to perform async initialization (database connections, API calls, etc.):
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
module.exports = async function(app, services) {
|
|
||||||
// Async initialization
|
|
||||||
await someAsyncSetup();
|
|
||||||
|
|
||||||
// Access services
|
|
||||||
if (services.syncService) {
|
|
||||||
console.log('Sync service available');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register routes
|
|
||||||
app.get('/api/my-route', (req, res) => {
|
|
||||||
res.json({ status: 'ready' });
|
|
||||||
});
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### Pattern 3: Lifecycle Hooks (Advanced)
|
|
||||||
|
|
||||||
For plugins that need cleanup on shutdown:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
module.exports = {
|
|
||||||
/**
|
|
||||||
* Called during server startup
|
|
||||||
*/
|
|
||||||
init: async (app, services) => {
|
|
||||||
// Setup code
|
|
||||||
this.interval = setInterval(() => {
|
|
||||||
console.log('Background task running...');
|
|
||||||
}, 60000);
|
|
||||||
|
|
||||||
app.get('/api/status', (req, res) => {
|
|
||||||
res.json({ uptime: process.uptime() });
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called on SIGTERM (graceful shutdown)
|
|
||||||
*/
|
|
||||||
shutdown: async () => {
|
|
||||||
// Cleanup code
|
|
||||||
if (this.interval) {
|
|
||||||
clearInterval(this.interval);
|
|
||||||
}
|
|
||||||
console.log('Plugin cleaned up');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Security & Permissions
|
|
||||||
|
|
||||||
> [!WARNING]
|
|
||||||
> Plugins run with **full Node.js permissions** and have access to the Express app and all services. Only install plugins from trusted sources.
|
|
||||||
|
|
||||||
**Important notes:**
|
|
||||||
- The `services` object is **frozen** - you cannot modify or delete services
|
|
||||||
- Plugins can still mutate properties of service objects (e.g., `services.cache.set()`)
|
|
||||||
- Plugins can register routes, middleware, and access the file system
|
|
||||||
- This is appropriate for self-hosted applications where you control what plugins are installed
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Available Services
|
|
||||||
|
|
||||||
The `services` object contains all modules from `server/services/`:
|
|
||||||
|
|
||||||
| Service | Description |
|
|
||||||
|---------|-------------|
|
|
||||||
| `cache` | Caching utilities |
|
|
||||||
| `epgParser` | EPG/XMLTV parsing |
|
|
||||||
| `hwDetect` | Hardware acceleration detection |
|
|
||||||
| `m3uParser` | M3U playlist parsing |
|
|
||||||
| `m3uXtreamAdapter` | Xtream API adapter |
|
|
||||||
| `syncService` | Channel/EPG synchronization |
|
|
||||||
| `transcodeSession` | Transcoding session management |
|
|
||||||
| `xtreamApi` | Xtream API client |
|
|
||||||
|
|
||||||
**Example:**
|
|
||||||
```javascript
|
|
||||||
module.exports = async function(app, services) {
|
|
||||||
// Use the EPG parser service
|
|
||||||
const epgData = await services.epgParser.fetchAndParse('http://example.com/epg.xml');
|
|
||||||
console.log(`Loaded ${epgData.programmes.length} programmes`);
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Load Order
|
|
||||||
|
|
||||||
Plugins are loaded in **alphabetical order** by filename. If you need specific ordering:
|
|
||||||
|
|
||||||
```
|
|
||||||
plugins/
|
|
||||||
01-database.js # Loads first
|
|
||||||
02-api-client.js # Loads second
|
|
||||||
99-cleanup.js # Loads last
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing Your Plugin
|
|
||||||
|
|
||||||
1. Place your `.js` file in `server/plugins/`
|
|
||||||
2. Restart the NodeCast TV server
|
|
||||||
3. Check the console for plugin loading messages:
|
|
||||||
- `✓ Loaded plugin: your-plugin.js` - Success
|
|
||||||
- `⚠ Plugin your-plugin.js does not export...` - Wrong export format
|
|
||||||
- `✗ Failed to load plugin your-plugin.js` - Error during initialization
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Example Use Cases
|
|
||||||
|
|
||||||
- **Custom scrapers** - Add support for new streaming sources
|
|
||||||
- **Notification systems** - Send alerts when new content is available
|
|
||||||
- **Analytics** - Track viewing patterns
|
|
||||||
- **Custom APIs** - Expose additional endpoints for integrations
|
|
||||||
- **Middleware** - Add authentication, logging, or rate limiting
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
/**
|
|
||||||
* Example plugin demonstrating async initialization and service access
|
|
||||||
* This plugin registers a test route at /api/hello
|
|
||||||
*/
|
|
||||||
module.exports = async function (app, services) {
|
|
||||||
console.log("Plugin 'Hello' activated!");
|
|
||||||
|
|
||||||
// Example: Access loaded services
|
|
||||||
if (services.syncService) {
|
|
||||||
console.log(" - syncService is available");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simulate async initialization (e.g., database connection, API setup)
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
|
||||||
|
|
||||||
// Register a test route accessible at http://localhost:3000/api/hello
|
|
||||||
app.get('/api/hello', (req, res) => {
|
|
||||||
res.json({
|
|
||||||
message: "The plugin system is working!",
|
|
||||||
availableServices: Object.keys(services)
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(" - Registered route: GET /api/hello");
|
|
||||||
};
|
|
||||||
+12
-47
@@ -1,7 +1,6 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { spawn } = require('child_process');
|
const { spawn } = require('child_process');
|
||||||
const streamInput = require('../utils/streamInput');
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Probe endpoint - detects stream codecs and container
|
* Probe endpoint - detects stream codecs and container
|
||||||
@@ -31,27 +30,16 @@ const BROWSER_AUDIO_CODECS = ['aac', 'mp3', 'opus', 'vorbis'];
|
|||||||
*/
|
*/
|
||||||
function probeStream(url, ffprobePath, userAgent = null, timeout = 15000) {
|
function probeStream(url, ffprobePath, userAgent = null, timeout = 15000) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const sizing = streamInput.probeSizingForUrl(url);
|
|
||||||
const referer = streamInput.deriveRefererFromUrl(url);
|
|
||||||
const effectiveTimeout = sizing.heavy ? Math.max(timeout, 45000) : timeout;
|
|
||||||
|
|
||||||
const args = [
|
const args = [
|
||||||
'-v', 'error',
|
'-v', 'error',
|
||||||
'-user_agent', userAgent || 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36'
|
'-user_agent', userAgent || 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
|
||||||
];
|
|
||||||
|
|
||||||
if (referer) {
|
|
||||||
args.push('-referer', referer);
|
|
||||||
}
|
|
||||||
|
|
||||||
args.push(
|
|
||||||
'-print_format', 'json',
|
'-print_format', 'json',
|
||||||
'-show_streams',
|
'-show_streams',
|
||||||
'-show_format',
|
'-show_format',
|
||||||
'-probesize', sizing.probesize,
|
'-probesize', '5000000',
|
||||||
'-analyzeduration', sizing.analyzeduration,
|
'-analyzeduration', '5000000',
|
||||||
url
|
url
|
||||||
);
|
];
|
||||||
|
|
||||||
const proc = spawn(ffprobePath, args);
|
const proc = spawn(ffprobePath, args);
|
||||||
let stdout = '';
|
let stdout = '';
|
||||||
@@ -60,7 +48,7 @@ function probeStream(url, ffprobePath, userAgent = null, timeout = 15000) {
|
|||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
proc.kill('SIGKILL');
|
proc.kill('SIGKILL');
|
||||||
reject(new Error('Probe timeout'));
|
reject(new Error('Probe timeout'));
|
||||||
}, effectiveTimeout);
|
}, timeout);
|
||||||
|
|
||||||
proc.stdout.on('data', (data) => { stdout += data; });
|
proc.stdout.on('data', (data) => { stdout += data; });
|
||||||
proc.stderr.on('data', (data) => { stderr += data; });
|
proc.stderr.on('data', (data) => { stderr += data; });
|
||||||
@@ -182,46 +170,22 @@ router.get('/', async (req, res) => {
|
|||||||
|
|
||||||
console.log(`[Probe] Probing: ${url.substring(0, 80)}... ${ua ? `(UA: ${ua})` : ''}`);
|
console.log(`[Probe] Probing: ${url.substring(0, 80)}... ${ua ? `(UA: ${ua})` : ''}`);
|
||||||
|
|
||||||
// Retry probe up to 3 times on 5XX errors (provider slot not released yet)
|
|
||||||
const MAX_PROBE_RETRIES = 3;
|
|
||||||
const PROBE_RETRY_DELAY_MS = 2500;
|
|
||||||
let lastErr = null;
|
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= MAX_PROBE_RETRIES; attempt++) {
|
|
||||||
try {
|
try {
|
||||||
const probeResult = await probeStream(url, ffprobePath, ua);
|
const probeResult = await probeStream(url, ffprobePath, ua);
|
||||||
const analysis = analyzeProbeResult(probeResult, url);
|
const analysis = analyzeProbeResult(probeResult, url);
|
||||||
|
|
||||||
// Cache successful result
|
// Cache result
|
||||||
probeCache.set(cacheKey, { result: analysis, timestamp: Date.now() });
|
probeCache.set(cacheKey, { result: analysis, timestamp: Date.now() });
|
||||||
|
|
||||||
console.log(`[Probe] Result (attempt ${attempt}): video=${analysis.video}, audio=${analysis.audio}, ` +
|
console.log(`[Probe] Result: video=${analysis.video}, audio=${analysis.audio}, ` +
|
||||||
`container=${analysis.container}, compatible=${analysis.compatible}, ` +
|
`container=${analysis.container}, compatible=${analysis.compatible}, ` +
|
||||||
`needsRemux=${analysis.needsRemux}, needsTranscode=${analysis.needsTranscode}`);
|
`needsRemux=${analysis.needsRemux}, needsTranscode=${analysis.needsTranscode}`);
|
||||||
|
|
||||||
return res.json(analysis);
|
res.json(analysis);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
lastErr = err;
|
console.error('[Probe] Failed:', err.message);
|
||||||
const is5xx = /5[0-9]{2}|Server Error/i.test(err.message);
|
|
||||||
if (is5xx && attempt < MAX_PROBE_RETRIES) {
|
|
||||||
console.warn(`[Probe] 5XX from provider (attempt ${attempt}/${MAX_PROBE_RETRIES}), retrying in ${PROBE_RETRY_DELAY_MS}ms...`);
|
|
||||||
await new Promise(r => setTimeout(r, PROBE_RETRY_DELAY_MS));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.error('[Probe] Failed after retries:', lastErr.message);
|
// On error, assume transcode needed to be safe
|
||||||
|
|
||||||
// If there's a stale cached result, use it rather than assuming needsTranscode
|
|
||||||
const stale = probeCache.get(cacheKey);
|
|
||||||
if (stale) {
|
|
||||||
console.warn('[Probe] Using stale cached result as fallback');
|
|
||||||
return res.json({ ...stale.result, stale: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Final fallback: assume transcode needed
|
|
||||||
res.json({
|
res.json({
|
||||||
video: 'unknown',
|
video: 'unknown',
|
||||||
audio: 'unknown',
|
audio: 'unknown',
|
||||||
@@ -229,8 +193,9 @@ router.get('/', async (req, res) => {
|
|||||||
compatible: false,
|
compatible: false,
|
||||||
needsRemux: false,
|
needsRemux: false,
|
||||||
needsTranscode: true,
|
needsTranscode: true,
|
||||||
error: lastErr.message
|
error: err.message
|
||||||
});
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
+18
-126
@@ -1,45 +1,10 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { spawn, execFile } = require('child_process');
|
const { spawn } = require('child_process');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
const db = require('../db');
|
const db = require('../db');
|
||||||
const transcodeSession = require('../services/transcodeSession');
|
const transcodeSession = require('../services/transcodeSession');
|
||||||
const streamInput = require('../utils/streamInput');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Probe a stream URL with FFprobe to get its total duration in seconds.
|
|
||||||
* VOD pesado usa probes mayores y timeout más alto (MKV Jellyfin/remux).
|
|
||||||
*/
|
|
||||||
function probeDuration(url, ffprobePath, userAgent) {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const sizing = streamInput.probeSizingForUrl(url);
|
|
||||||
const referer = streamInput.deriveRefererFromUrl(url);
|
|
||||||
const args = [
|
|
||||||
'-v', 'quiet',
|
|
||||||
'-print_format', 'json',
|
|
||||||
'-show_entries', 'format=duration',
|
|
||||||
'-user_agent', userAgent || 'Mozilla/5.0',
|
|
||||||
'-probesize', sizing.probesize,
|
|
||||||
'-analyzeduration', sizing.analyzeduration,
|
|
||||||
];
|
|
||||||
if (referer) {
|
|
||||||
args.push('-referer', referer);
|
|
||||||
}
|
|
||||||
args.push(url);
|
|
||||||
const timeoutMs = sizing.heavy ? 45000 : 8000;
|
|
||||||
const proc = execFile(ffprobePath || 'ffprobe', args, { timeout: timeoutMs }, (err, stdout) => {
|
|
||||||
if (err) { resolve(0); return; }
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(stdout);
|
|
||||||
const dur = parseFloat(data?.format?.duration);
|
|
||||||
resolve(Number.isFinite(dur) && dur > 0 ? Math.round(dur) : 0);
|
|
||||||
} catch { resolve(0); }
|
|
||||||
});
|
|
||||||
// Ensure the process is killed if execFile timeout fires
|
|
||||||
proc.on('error', () => resolve(0));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transcode Routes
|
* Transcode Routes
|
||||||
@@ -75,78 +40,38 @@ router.post('/session', async (req, res) => {
|
|||||||
const userAgent = db.getUserAgent(settings);
|
const userAgent = db.getUserAgent(settings);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const ffprobePath = (ffmpegPath || 'ffmpeg').replace(/ffmpeg$/, 'ffprobe');
|
const session = await transcodeSession.createSession(url, {
|
||||||
|
|
||||||
// Probe duration and start session in parallel (don't block on duration)
|
|
||||||
const [session, durationSec] = await Promise.all([
|
|
||||||
transcodeSession.createSession(url, {
|
|
||||||
ffmpegPath,
|
ffmpegPath,
|
||||||
userAgent,
|
userAgent,
|
||||||
seekOffset: seekOffset || 0,
|
seekOffset: seekOffset || 0,
|
||||||
hwEncoder: settings.hwEncoder || 'software',
|
hwEncoder: settings.hwEncoder || 'software',
|
||||||
maxResolution: settings.maxResolution || '1080p',
|
maxResolution: settings.maxResolution || '1080p',
|
||||||
quality: settings.quality || 'medium',
|
quality: settings.quality || 'medium',
|
||||||
audioMixPreset: settings.audioMixPreset || 'auto',
|
audioMixPreset: settings.audioMixPreset || 'auto', // Audio downmix preset
|
||||||
|
// Upscaling options
|
||||||
upscaleEnabled: settings.upscaleEnabled || false,
|
upscaleEnabled: settings.upscaleEnabled || false,
|
||||||
upscaleMethod: settings.upscaleMethod || 'hardware',
|
upscaleMethod: settings.upscaleMethod || 'hardware',
|
||||||
upscaleTarget: settings.upscaleTarget || '1080p',
|
upscaleTarget: settings.upscaleTarget || '1080p',
|
||||||
videoMode: videoMode,
|
videoMode: videoMode, // 'copy' or 'encode'
|
||||||
videoCodec: videoCodec,
|
videoCodec: videoCodec, // 'h264', 'hevc', etc.
|
||||||
audioCodec: audioCodec,
|
audioCodec: audioCodec, // 'aac', 'ac3', etc.
|
||||||
audioChannels: audioChannels
|
audioChannels: audioChannels // number of channels (2=stereo)
|
||||||
}).then(async s => { await s.start(); return s; }),
|
});
|
||||||
probeDuration(url, ffprobePath, userAgent)
|
|
||||||
]);
|
await session.start();
|
||||||
|
|
||||||
// Wait for playlist to be ready (first segments generated)
|
// Wait for playlist to be ready (first segments generated)
|
||||||
// VOD pesado (4K HEVC/remux) puede tardar >15 s en generar el primer segmento
|
const ready = await session.waitForPlaylist(15000);
|
||||||
const waitTimeout = streamInput.isHeavyVodUrl(url) ? 60000 : 15000;
|
|
||||||
let ready = await session.waitForPlaylist(waitTimeout);
|
|
||||||
|
|
||||||
// Fallback a software si el encoder HW falla inmediatamente (error rápido)
|
|
||||||
if (!ready && session.status === 'error' && (settings.hwEncoder || 'software') !== 'software') {
|
|
||||||
console.warn(`[Transcode] HW encoder (${settings.hwEncoder}) falló, reintentando con software libx264...`);
|
|
||||||
await transcodeSession.removeSession(session.id);
|
|
||||||
const swSession = await transcodeSession.createSession(url, {
|
|
||||||
ffmpegPath,
|
|
||||||
userAgent,
|
|
||||||
seekOffset: seekOffset || 0,
|
|
||||||
hwEncoder: 'software',
|
|
||||||
maxResolution: settings.maxResolution || '1080p',
|
|
||||||
quality: settings.quality || 'medium',
|
|
||||||
audioMixPreset: settings.audioMixPreset || 'auto',
|
|
||||||
upscaleEnabled: false,
|
|
||||||
videoMode: videoMode,
|
|
||||||
videoCodec: videoCodec,
|
|
||||||
audioCodec: audioCodec,
|
|
||||||
audioChannels: audioChannels
|
|
||||||
});
|
|
||||||
await swSession.start();
|
|
||||||
ready = await swSession.waitForPlaylist(waitTimeout);
|
|
||||||
if (ready) {
|
|
||||||
console.log(`[Transcode] Software fallback OK → session ${swSession.id}`);
|
|
||||||
return res.json({
|
|
||||||
sessionId: swSession.id,
|
|
||||||
playlistUrl: `/api/transcode/${swSession.id}/stream.m3u8`,
|
|
||||||
status: swSession.status,
|
|
||||||
durationSec: durationSec || 0
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await transcodeSession.removeSession(swSession.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ready) {
|
if (!ready) {
|
||||||
await transcodeSession.removeSession(session.id);
|
await transcodeSession.removeSession(session.id);
|
||||||
return res.status(500).json({ error: 'Transcoding failed to start', reason: 'Playlist not generated in time' });
|
return res.status(500).json({ error: 'Transcoding failed to start', reason: 'Playlist not generated in time' });
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[Transcode] Session ${session.id} ready. Source duration: ${durationSec}s`);
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
sessionId: session.id,
|
sessionId: session.id,
|
||||||
playlistUrl: `/api/transcode/${session.id}/stream.m3u8`,
|
playlistUrl: `/api/transcode/${session.id}/stream.m3u8`,
|
||||||
status: session.status,
|
status: session.status
|
||||||
durationSec: durationSec || 0
|
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -219,30 +144,6 @@ router.delete('/:sessionId', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Heartbeat — keeps a session alive while the player is active.
|
|
||||||
* POST /api/transcode/:sessionId/heartbeat
|
|
||||||
*/
|
|
||||||
router.post('/:sessionId/heartbeat', (req, res) => {
|
|
||||||
const session = transcodeSession.getSession(req.params.sessionId);
|
|
||||||
if (!session) return res.status(404).json({ error: 'Session not found' });
|
|
||||||
session.lastAccess = Date.now();
|
|
||||||
res.json({ ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop session via POST (for sendBeacon on page close)
|
|
||||||
* POST /api/transcode/:sessionId/stop
|
|
||||||
*/
|
|
||||||
router.post('/:sessionId/stop', async (req, res) => {
|
|
||||||
try {
|
|
||||||
await transcodeSession.removeSession(req.params.sessionId);
|
|
||||||
res.json({ success: true });
|
|
||||||
} catch (err) {
|
|
||||||
res.status(500).json({ error: 'Failed to remove session', details: err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List all active sessions (for debugging)
|
* List all active sessions (for debugging)
|
||||||
* GET /api/transcode/sessions
|
* GET /api/transcode/sessions
|
||||||
@@ -274,9 +175,6 @@ router.get('/', async (req, res) => {
|
|||||||
console.log(`[Transcode] Using User-Agent: ${settings.userAgentPreset}`);
|
console.log(`[Transcode] Using User-Agent: ${settings.userAgentPreset}`);
|
||||||
console.log(`[Transcode] Using binary: ${ffmpegPath}`);
|
console.log(`[Transcode] Using binary: ${ffmpegPath}`);
|
||||||
|
|
||||||
const sizing = streamInput.probeSizingForUrl(url);
|
|
||||||
const referer = streamInput.deriveRefererFromUrl(url);
|
|
||||||
|
|
||||||
// FFmpeg arguments for transcoding
|
// FFmpeg arguments for transcoding
|
||||||
// Optimized for VOD content with incompatible audio (Dolby/AC3/EAC3)
|
// Optimized for VOD content with incompatible audio (Dolby/AC3/EAC3)
|
||||||
// Also works for live streams with ad stitching (Pluto TV, etc.)
|
// Also works for live streams with ad stitching (Pluto TV, etc.)
|
||||||
@@ -284,13 +182,9 @@ router.get('/', async (req, res) => {
|
|||||||
'-hide_banner',
|
'-hide_banner',
|
||||||
'-loglevel', 'warning',
|
'-loglevel', 'warning',
|
||||||
'-user_agent', userAgent,
|
'-user_agent', userAgent,
|
||||||
];
|
// Faster startup - reduced probe/analyze for quicker first bytes
|
||||||
if (referer) {
|
'-probesize', '2000000', // 2MB (reduced from 5MB)
|
||||||
args.push('-referer', referer);
|
'-analyzeduration', '3000000', // 3 seconds (reduced from 10s)
|
||||||
}
|
|
||||||
args.push(
|
|
||||||
'-probesize', sizing.probesize,
|
|
||||||
'-analyzeduration', sizing.analyzeduration,
|
|
||||||
// Error resilience: generate timestamps, discard corrupt packets
|
// Error resilience: generate timestamps, discard corrupt packets
|
||||||
'-fflags', '+genpts+discardcorrupt+nobuffer',
|
'-fflags', '+genpts+discardcorrupt+nobuffer',
|
||||||
// Ignore errors in stream and continue
|
// Ignore errors in stream and continue
|
||||||
@@ -303,9 +197,7 @@ router.get('/', async (req, res) => {
|
|||||||
'-reconnect_delay_max', '3',
|
'-reconnect_delay_max', '3',
|
||||||
// Prevent Range/HEAD requests that some providers reject with 405
|
// Prevent Range/HEAD requests that some providers reject with 405
|
||||||
'-seekable', '0',
|
'-seekable', '0',
|
||||||
'-i', url
|
'-i', url,
|
||||||
);
|
|
||||||
args.push(
|
|
||||||
// Map only first video and audio stream (avoid subtitle streams causing issues)
|
// Map only first video and audio stream (avoid subtitle streams causing issues)
|
||||||
'-map', '0:v:0',
|
'-map', '0:v:0',
|
||||||
'-map', '0:a:0?', // ? makes audio optional if not present
|
'-map', '0:a:0?', // ? makes audio optional if not present
|
||||||
@@ -326,7 +218,7 @@ router.get('/', async (req, res) => {
|
|||||||
'-movflags', 'frag_keyframe+empty_moov+default_base_moof+faststart',
|
'-movflags', 'frag_keyframe+empty_moov+default_base_moof+faststart',
|
||||||
'-flush_packets', '1', // Send data immediately
|
'-flush_packets', '1', // Send data immediately
|
||||||
'-' // Output to stdout
|
'-' // Output to stdout
|
||||||
);
|
];
|
||||||
|
|
||||||
console.log(`[Transcode] Full command: ${ffmpegPath} ${args.join(' ')}`);
|
console.log(`[Transcode] Full command: ${ffmpegPath} ${args.join(' ')}`);
|
||||||
|
|
||||||
|
|||||||
@@ -228,18 +228,16 @@ async function detect() {
|
|||||||
detectAMF()
|
detectAMF()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Determine recommended encoder
|
// Determine recommended encoder (priority: NVENC > AMF > QSV > VAAPI > Software)
|
||||||
// On Linux: prefer VAAPI over QSV — VAAPI is more reliable in LXC containers
|
|
||||||
// (QSV may fail to initialize the MFX device even when /dev/dri is passed through)
|
|
||||||
let recommended = 'software';
|
let recommended = 'software';
|
||||||
if (nvidia.available) {
|
if (nvidia.available) {
|
||||||
recommended = 'nvenc';
|
recommended = 'nvenc';
|
||||||
} else if (amf.available) {
|
} else if (amf.available) {
|
||||||
recommended = 'amf';
|
recommended = 'amf';
|
||||||
} else if (vaapi.available) {
|
|
||||||
recommended = 'vaapi';
|
|
||||||
} else if (qsv.available) {
|
} else if (qsv.available) {
|
||||||
recommended = 'qsv';
|
recommended = 'qsv';
|
||||||
|
} else if (vaapi.available) {
|
||||||
|
recommended = 'vaapi';
|
||||||
}
|
}
|
||||||
|
|
||||||
hwCapabilities = {
|
hwCapabilities = {
|
||||||
|
|||||||
@@ -1,781 +0,0 @@
|
|||||||
/**
|
|
||||||
* Transcode Session Service
|
|
||||||
*
|
|
||||||
* Manages HLS transcoding sessions with segment caching for VOD seeking.
|
|
||||||
* Each session transcodes a source URL to HLS segments on disk.
|
|
||||||
*
|
|
||||||
* Key features:
|
|
||||||
* - Session-based transcoding with unique IDs
|
|
||||||
* - HLS segment output for seeking support
|
|
||||||
* - Segment caching for fast access
|
|
||||||
* - Session persistence for recovery after restart
|
|
||||||
* - Automatic cleanup of stale sessions
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { spawn } = require('child_process');
|
|
||||||
const path = require('path');
|
|
||||||
const fs = require('fs').promises;
|
|
||||||
const crypto = require('crypto');
|
|
||||||
const EventEmitter = require('events');
|
|
||||||
const hwDetect = require('./hwDetect');
|
|
||||||
|
|
||||||
// Session storage
|
|
||||||
const sessions = new Map();
|
|
||||||
|
|
||||||
// Cache directory for transcoded segments
|
|
||||||
const CACHE_DIR = path.join(process.cwd(), 'transcode-cache');
|
|
||||||
|
|
||||||
// Session settings
|
|
||||||
const SESSION_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes idle timeout
|
|
||||||
const SEGMENT_DURATION = 4; // seconds per HLS segment
|
|
||||||
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // Check every 5 minutes
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a unique session ID
|
|
||||||
*/
|
|
||||||
function generateSessionId() {
|
|
||||||
return crypto.randomBytes(8).toString('hex');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensure cache directory exists
|
|
||||||
*/
|
|
||||||
async function ensureCacheDir() {
|
|
||||||
try {
|
|
||||||
await fs.mkdir(CACHE_DIR, { recursive: true });
|
|
||||||
} catch (err) {
|
|
||||||
if (err.code !== 'EEXIST') throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* TranscodeSession class
|
|
||||||
* Manages a single transcoding session from source URL to HLS segments
|
|
||||||
*/
|
|
||||||
class TranscodeSession extends EventEmitter {
|
|
||||||
constructor(url, options = {}) {
|
|
||||||
super();
|
|
||||||
this.id = generateSessionId();
|
|
||||||
this.url = url;
|
|
||||||
this.dir = path.join(CACHE_DIR, this.id);
|
|
||||||
this.playlistPath = path.join(this.dir, 'stream.m3u8');
|
|
||||||
this.process = null;
|
|
||||||
this.segments = new Map(); // segment index -> { ready: boolean, path: string }
|
|
||||||
this.status = 'pending'; // pending | starting | running | stopped | error
|
|
||||||
this.error = null;
|
|
||||||
this.startTime = Date.now();
|
|
||||||
this.lastAccess = Date.now();
|
|
||||||
this.options = {
|
|
||||||
ffmpegPath: options.ffmpegPath || 'ffmpeg',
|
|
||||||
userAgent: options.userAgent || 'Mozilla/5.0',
|
|
||||||
seekOffset: options.seekOffset || 0,
|
|
||||||
hwEncoder: options.hwEncoder || 'software',
|
|
||||||
maxResolution: options.maxResolution || '1080p',
|
|
||||||
quality: options.quality || 'medium',
|
|
||||||
// Upscaling options
|
|
||||||
upscaleEnabled: options.upscaleEnabled || false,
|
|
||||||
upscaleMethod: options.upscaleMethod || 'hardware', // 'hardware' or 'software'
|
|
||||||
upscaleTarget: options.upscaleTarget || '1080p',
|
|
||||||
...options
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start the transcoding process
|
|
||||||
*/
|
|
||||||
async start() {
|
|
||||||
if (this.status === 'running') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.status = 'starting';
|
|
||||||
console.log(`[TranscodeSession ${this.id}] Starting session for: ${this.url}`);
|
|
||||||
|
|
||||||
// Create session directory
|
|
||||||
try {
|
|
||||||
await fs.mkdir(this.dir, { recursive: true });
|
|
||||||
} catch (err) {
|
|
||||||
this.status = 'error';
|
|
||||||
this.error = err.message;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build FFmpeg arguments for HLS output
|
|
||||||
const args = this.buildFFmpegArgs();
|
|
||||||
|
|
||||||
console.log(`[TranscodeSession ${this.id}] Command: ${this.options.ffmpegPath} ${args.join(' ')}`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.process = spawn(this.options.ffmpegPath, args, {
|
|
||||||
cwd: this.dir,
|
|
||||||
windowsHide: true
|
|
||||||
});
|
|
||||||
|
|
||||||
this.status = 'running';
|
|
||||||
|
|
||||||
// Handle stdout (should be empty for file output)
|
|
||||||
this.process.stdout.on('data', (data) => {
|
|
||||||
console.log(`[TranscodeSession ${this.id}] stdout: ${data}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle stderr (FFmpeg progress/errors)
|
|
||||||
let stderrBuffer = '';
|
|
||||||
this.process.stderr.on('data', (data) => {
|
|
||||||
stderrBuffer += data.toString();
|
|
||||||
// Log periodically to avoid spam
|
|
||||||
const lines = stderrBuffer.split('\n');
|
|
||||||
if (lines.length > 1) {
|
|
||||||
lines.slice(0, -1).forEach(line => {
|
|
||||||
if (line.trim()) {
|
|
||||||
console.log(`[FFmpeg ${this.id}] ${line}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
stderrBuffer = lines[lines.length - 1];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle process exit
|
|
||||||
this.process.on('exit', (code) => {
|
|
||||||
if (code === 0 || code === null) {
|
|
||||||
console.log(`[TranscodeSession ${this.id}] FFmpeg completed successfully`);
|
|
||||||
this.status = 'stopped';
|
|
||||||
} else if (code !== 255) { // 255 is often from SIGKILL
|
|
||||||
console.error(`[TranscodeSession ${this.id}] FFmpeg exited with code ${code}`);
|
|
||||||
this.status = 'error';
|
|
||||||
this.error = `FFmpeg exited with code ${code}`;
|
|
||||||
}
|
|
||||||
this.process = null;
|
|
||||||
this.emit('exit', code);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle spawn errors
|
|
||||||
this.process.on('error', (err) => {
|
|
||||||
console.error(`[TranscodeSession ${this.id}] FFmpeg error:`, err);
|
|
||||||
this.status = 'error';
|
|
||||||
this.error = err.message;
|
|
||||||
this.emit('error', err);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Save session metadata
|
|
||||||
await this.persist();
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
this.status = 'error';
|
|
||||||
this.error = err.message;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build FFmpeg arguments for HLS output with optional GPU encoding
|
|
||||||
*/
|
|
||||||
buildFFmpegArgs() {
|
|
||||||
const segmentPattern = path.join(this.dir, 'seg%04d.m4s');
|
|
||||||
const videoMode = this.options.videoMode || 'encode';
|
|
||||||
|
|
||||||
// Resolve 'auto' encoder to detected hardware, fallback to software
|
|
||||||
let encoder = this.options.hwEncoder || 'software';
|
|
||||||
if (encoder === 'auto') {
|
|
||||||
const hwCaps = hwDetect.getCapabilities();
|
|
||||||
encoder = hwCaps?.recommended || 'software';
|
|
||||||
console.log(`[TranscodeSession ${this.id}] Auto encoder resolved to: ${encoder}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const args = [
|
|
||||||
'-hide_banner',
|
|
||||||
'-loglevel', 'warning',
|
|
||||||
'-user_agent', this.options.userAgent,
|
|
||||||
];
|
|
||||||
|
|
||||||
// Add hardware acceleration input options based on encoder (only if encoding)
|
|
||||||
if (videoMode === 'encode') {
|
|
||||||
this.addHwAccelInputArgs(args, encoder);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Input options (common)
|
|
||||||
args.push(
|
|
||||||
'-probesize', '5000000',
|
|
||||||
'-analyzeduration', '5000000',
|
|
||||||
'-fflags', '+genpts+discardcorrupt',
|
|
||||||
'-err_detect', 'ignore_err',
|
|
||||||
'-reconnect', '1',
|
|
||||||
'-reconnect_streamed', '1',
|
|
||||||
'-reconnect_delay_max', '3'
|
|
||||||
);
|
|
||||||
|
|
||||||
args.push('-i', this.url);
|
|
||||||
|
|
||||||
// Add seek offset if specified (as output option to avoid Range requests)
|
|
||||||
if (this.options.seekOffset > 0) {
|
|
||||||
args.push('-ss', String(this.options.seekOffset));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map streams
|
|
||||||
args.push('-map', '0:v:0');
|
|
||||||
args.push('-map', '0:a:0?');
|
|
||||||
|
|
||||||
// Add video encoder and filters based on selected encoder OR copy
|
|
||||||
if (videoMode === 'copy') {
|
|
||||||
args.push('-c:v', 'copy');
|
|
||||||
|
|
||||||
// Critical for MKV/MP4 -> TS copy: Convert bitstream from AVCC/HVCC to Annex B
|
|
||||||
if (this.options.videoCodec === 'hevc' || this.options.videoCodec === 'h265') {
|
|
||||||
args.push('-bsf:v', 'hevc_mp4toannexb');
|
|
||||||
} else if (this.options.videoCodec === 'h264' || this.options.videoCodec === 'avc') {
|
|
||||||
// Keep Annex-B conversion and force parameter sets to be repeated.
|
|
||||||
// This helps players recover on streams where SPS/PPS appear sporadically,
|
|
||||||
// which often manifests as "audio OK, black video".
|
|
||||||
args.push('-bsf:v', 'h264_mp4toannexb,dump_extra');
|
|
||||||
} else {
|
|
||||||
// Fallback (e.g. unknown codec), try strict extraction
|
|
||||||
args.push('-bsf:v', 'dump_extra');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.addVideoEncoderArgs(args, encoder);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Audio: Apply mix preset
|
|
||||||
const audioCodec = this.options.audioCodec?.toLowerCase() || 'unknown';
|
|
||||||
const audioChannels = this.options.audioChannels || 0;
|
|
||||||
const audioMixPreset = this.options.audioMixPreset || 'auto';
|
|
||||||
const isStereoAac = audioCodec.includes('aac') && audioChannels === 2;
|
|
||||||
|
|
||||||
// Define pan filter presets for 5.1 -> Stereo downmix
|
|
||||||
const AUDIO_MIX_FILTERS = {
|
|
||||||
// ITU-R BS.775 Standard: Mathematically balanced, transparent
|
|
||||||
itu: 'pan=stereo|FL=FL+0.707*FC+0.707*BL+0.5*LFE|FR=FR+0.707*FC+0.707*BR+0.5*LFE',
|
|
||||||
// Night Mode: Heavy dialogue boost, reduced bass/surrounds for quiet viewing
|
|
||||||
night: 'pan=stereo|FL=0.5*FL+1.2*FC+0.3*BL+0.1*LFE|FR=0.5*FR+1.2*FC+0.3*BR+0.1*LFE',
|
|
||||||
// Cinematic: Wide soundstage, immersive (original "dialogue boost" mix)
|
|
||||||
cinematic: 'pan=stereo|FL=FC+0.80*FL+0.60*BL+0.5*LFE|FR=FC+0.80*FR+0.60*BR+0.5*LFE'
|
|
||||||
};
|
|
||||||
|
|
||||||
if (audioMixPreset === 'passthrough') {
|
|
||||||
// Passthrough: Always copy audio, no processing
|
|
||||||
console.log(`[TranscodeSession ${this.id}] Audio: Passthrough (copy)`);
|
|
||||||
args.push('-c:a', 'copy');
|
|
||||||
} else if (audioMixPreset === 'auto' && isStereoAac) {
|
|
||||||
// Auto + Stereo AAC source: Smart copy
|
|
||||||
console.log(`[TranscodeSession ${this.id}] Audio: Auto (Smart Copy) - Source is Stereo AAC`);
|
|
||||||
args.push('-c:a', 'copy');
|
|
||||||
} else {
|
|
||||||
// Transcode to AAC with selected mix preset (default to ITU for 'auto')
|
|
||||||
const mixPreset = (audioMixPreset === 'auto') ? 'itu' : audioMixPreset;
|
|
||||||
const panFilter = AUDIO_MIX_FILTERS[mixPreset] || AUDIO_MIX_FILTERS.itu;
|
|
||||||
|
|
||||||
console.log(`[TranscodeSession ${this.id}] Audio: ${mixPreset.toUpperCase()} mix (${audioCodec} ${audioChannels}ch -> Stereo AAC)`);
|
|
||||||
args.push(
|
|
||||||
'-c:a', 'aac',
|
|
||||||
'-ar', '48000',
|
|
||||||
'-b:a', '192k',
|
|
||||||
'-af', `${panFilter},aresample=async=1`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// HLS output options
|
|
||||||
args.push(
|
|
||||||
'-f', 'hls',
|
|
||||||
'-hls_time', String(SEGMENT_DURATION),
|
|
||||||
'-hls_list_size', '0', // Keep all segments in playlist
|
|
||||||
'-hls_flags', 'independent_segments+append_list',
|
|
||||||
'-hls_segment_type', 'mpegts',
|
|
||||||
'-hls_segment_filename', path.join(this.dir, 'seg%04d.ts'),
|
|
||||||
this.playlistPath
|
|
||||||
);
|
|
||||||
|
|
||||||
return args;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add hardware acceleration input arguments
|
|
||||||
*/
|
|
||||||
addHwAccelInputArgs(args, encoder) {
|
|
||||||
switch (encoder) {
|
|
||||||
case 'nvenc':
|
|
||||||
// NVIDIA CUDA/NVDEC hardware decoding
|
|
||||||
args.push(
|
|
||||||
'-hwaccel', 'cuda',
|
|
||||||
'-hwaccel_output_format', 'cuda'
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
case 'vaapi':
|
|
||||||
// VAAPI hardware decoding (Linux)
|
|
||||||
args.push(
|
|
||||||
'-hwaccel', 'vaapi',
|
|
||||||
'-hwaccel_device', '/dev/dri/renderD128',
|
|
||||||
'-hwaccel_output_format', 'vaapi'
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
case 'qsv':
|
|
||||||
// Intel QuickSync hardware decoding
|
|
||||||
args.push(
|
|
||||||
'-hwaccel', 'qsv',
|
|
||||||
'-hwaccel_output_format', 'qsv'
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
case 'amf':
|
|
||||||
// AMD AMF (no hwaccel input, AMF is encode-only)
|
|
||||||
// Decode on CPU, encode on GPU
|
|
||||||
break;
|
|
||||||
case 'software':
|
|
||||||
case 'auto':
|
|
||||||
default:
|
|
||||||
// No hardware acceleration for input
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add video encoder arguments based on selected encoder
|
|
||||||
*/
|
|
||||||
addVideoEncoderArgs(args, encoder) {
|
|
||||||
const resolution = this.getTargetHeight();
|
|
||||||
const quality = this.options.quality || 'medium';
|
|
||||||
|
|
||||||
// Quality presets mapping
|
|
||||||
const qualityPresets = {
|
|
||||||
'high': { nvenc: 18, vaapi: 18, qsv: 18, amf: 18, software: 18 },
|
|
||||||
'medium': { nvenc: 24, vaapi: 24, qsv: 24, amf: 24, software: 23 },
|
|
||||||
'low': { nvenc: 30, vaapi: 30, qsv: 30, amf: 30, software: 28 }
|
|
||||||
};
|
|
||||||
const qp = qualityPresets[quality] || qualityPresets.medium;
|
|
||||||
|
|
||||||
switch (encoder) {
|
|
||||||
case 'nvenc':
|
|
||||||
this.addNvencEncoderArgs(args, resolution, qp.nvenc);
|
|
||||||
break;
|
|
||||||
case 'amf':
|
|
||||||
this.addAmfEncoderArgs(args, resolution, qp.amf);
|
|
||||||
break;
|
|
||||||
case 'vaapi':
|
|
||||||
this.addVaapiEncoderArgs(args, resolution, qp.vaapi);
|
|
||||||
break;
|
|
||||||
case 'qsv':
|
|
||||||
this.addQsvEncoderArgs(args, resolution, qp.qsv);
|
|
||||||
break;
|
|
||||||
case 'software':
|
|
||||||
case 'auto':
|
|
||||||
default:
|
|
||||||
this.addSoftwareEncoderArgs(args, resolution, qp.software);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get target height based on maxResolution or upscaleTarget setting
|
|
||||||
* When upscaling is enabled, uses the upscaleTarget resolution.
|
|
||||||
* Otherwise, uses maxResolution to cap the output.
|
|
||||||
*/
|
|
||||||
getTargetHeight() {
|
|
||||||
const resolutionMap = {
|
|
||||||
'4k': 2160,
|
|
||||||
'1080p': 1080,
|
|
||||||
'720p': 720,
|
|
||||||
'480p': 480
|
|
||||||
};
|
|
||||||
|
|
||||||
// When upscaling is enabled, use the upscale target resolution
|
|
||||||
if (this.options.upscaleEnabled) {
|
|
||||||
const target = resolutionMap[this.options.upscaleTarget] || 1080;
|
|
||||||
console.log(`[TranscodeSession ${this.id}] Upscale target height: ${target}p`);
|
|
||||||
return target;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise, use max resolution as the cap
|
|
||||||
return resolutionMap[this.options.maxResolution] || 1080;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build scale filter string based on encoder and upscaling settings
|
|
||||||
* @param {string} encoder - The encoder being used
|
|
||||||
* @param {number} height - Target height
|
|
||||||
*/
|
|
||||||
buildScaleFilter(encoder, height) {
|
|
||||||
const useUpscale = this.options.upscaleEnabled;
|
|
||||||
const upscaleMethod = this.options.upscaleMethod || 'hardware';
|
|
||||||
|
|
||||||
// Log upscaling status
|
|
||||||
if (useUpscale) {
|
|
||||||
console.log(`[TranscodeSession ${this.id}] Upscaling: ${upscaleMethod} method to ${height}p`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hardware scaling filters (for both upscale and downscale)
|
|
||||||
if (upscaleMethod === 'hardware' || !useUpscale) {
|
|
||||||
switch (encoder) {
|
|
||||||
case 'nvenc':
|
|
||||||
// NVIDIA CUDA scaling with Lanczos
|
|
||||||
// Force nv12 (8-bit) output to handle 10-bit inputs (fixes "10 bit encode not supported")
|
|
||||||
return `scale_cuda=-2:${height}:interp_algo=lanczos:format=nv12`;
|
|
||||||
case 'vaapi':
|
|
||||||
return `scale_vaapi=w=-2:h=${height}:format=nv12`;
|
|
||||||
case 'qsv':
|
|
||||||
return `scale_qsv=w=-2:h=${height}:format=nv12`;
|
|
||||||
case 'amf':
|
|
||||||
// AMF uses CPU decode, so use software scale
|
|
||||||
return useUpscale ? `scale=-2:${height}:flags=lanczos` : `scale=-2:${height}`;
|
|
||||||
case 'software':
|
|
||||||
default:
|
|
||||||
return useUpscale ? `scale=-2:${height}:flags=lanczos` : `scale=-2:${height}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Software Lanczos scaling (high quality, slower)
|
|
||||||
return `scale=-2:${height}:flags=lanczos`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* NVIDIA NVENC encoder arguments
|
|
||||||
*/
|
|
||||||
addNvencEncoderArgs(args, height, qp) {
|
|
||||||
// Video filter for scaling on GPU
|
|
||||||
args.push('-vf', this.buildScaleFilter('nvenc', height));
|
|
||||||
|
|
||||||
// NVENC encoder with quality settings
|
|
||||||
// Using portable options that work across FFmpeg builds
|
|
||||||
args.push(
|
|
||||||
'-c:v', 'h264_nvenc',
|
|
||||||
'-preset', 'p4', // Balanced preset (p1=fastest, p7=best)
|
|
||||||
'-rc', 'constqp', // Constant QP mode
|
|
||||||
'-qp', String(qp),
|
|
||||||
'-bf', '3' // B-frames for better compression
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* AMD AMF encoder arguments
|
|
||||||
*/
|
|
||||||
addAmfEncoderArgs(args, height, qp) {
|
|
||||||
// CPU decoding + software scale + AMF encode
|
|
||||||
args.push('-vf', this.buildScaleFilter('amf', height));
|
|
||||||
|
|
||||||
args.push(
|
|
||||||
'-c:v', 'h264_amf',
|
|
||||||
'-quality', 'quality', // Quality preset
|
|
||||||
'-rc', 'cqp', // Constant QP
|
|
||||||
'-qp_i', String(qp),
|
|
||||||
'-qp_p', String(qp + 2),
|
|
||||||
'-qp_b', String(qp + 4),
|
|
||||||
'-pix_fmt', 'yuv420p' // Force 8-bit output for compatibility
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* VAAPI encoder arguments (Linux)
|
|
||||||
*/
|
|
||||||
addVaapiEncoderArgs(args, height, qp) {
|
|
||||||
// VAAPI filter chain:
|
|
||||||
// 1. scale_vaapi to resize on GPU
|
|
||||||
// 2. Ensure output format is nv12 for maximum encoder compatibility
|
|
||||||
// The format is handled automatically when using -hwaccel_output_format vaapi
|
|
||||||
args.push('-vf', this.buildScaleFilter('vaapi', height));
|
|
||||||
|
|
||||||
// VAAPI encoder with quality setting
|
|
||||||
// Note: -global_quality is the portable way to set quality for VAAPI
|
|
||||||
args.push(
|
|
||||||
'-c:v', 'h264_vaapi',
|
|
||||||
'-profile:v', 'main', // Use main profile for compatibility
|
|
||||||
'-global_quality', String(qp),
|
|
||||||
'-bf', '3',
|
|
||||||
'-pix_fmt', 'yuv420p' // Force 8-bit output for compatibility
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Intel QuickSync encoder arguments
|
|
||||||
*/
|
|
||||||
addQsvEncoderArgs(args, height, qp) {
|
|
||||||
// Scale on QSV
|
|
||||||
args.push('-vf', this.buildScaleFilter('qsv', height));
|
|
||||||
|
|
||||||
args.push(
|
|
||||||
'-c:v', 'h264_qsv',
|
|
||||||
'-preset', 'medium',
|
|
||||||
'-global_quality', String(qp),
|
|
||||||
'-look_ahead', '1',
|
|
||||||
'-look_ahead_depth', '40',
|
|
||||||
'-pix_fmt', 'yuv420p' // Force 8-bit output for compatibility
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Software encoder arguments (fallback)
|
|
||||||
*/
|
|
||||||
addSoftwareEncoderArgs(args, height, crf) {
|
|
||||||
// Software scaling (use Lanczos for upscaling if enabled)
|
|
||||||
args.push('-vf', this.buildScaleFilter('software', height));
|
|
||||||
|
|
||||||
args.push(
|
|
||||||
'-c:v', 'libx264',
|
|
||||||
'-preset', 'veryfast', // Fast for real-time
|
|
||||||
'-crf', String(crf),
|
|
||||||
'-profile:v', 'high',
|
|
||||||
'-level', '4.1',
|
|
||||||
'-pix_fmt', 'yuv420p' // Force 8-bit output for compatibility (fixes 10-bit input errors)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop the transcoding process
|
|
||||||
*/
|
|
||||||
stop() {
|
|
||||||
if (this.process) {
|
|
||||||
console.log(`[TranscodeSession ${this.id}] Stopping FFmpeg process`);
|
|
||||||
this.process.kill('SIGTERM');
|
|
||||||
// Force kill after 2 seconds if still running
|
|
||||||
setTimeout(() => {
|
|
||||||
if (this.process) {
|
|
||||||
this.process.kill('SIGKILL');
|
|
||||||
}
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
this.status = 'stopped';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update last access time (prevents cleanup)
|
|
||||||
*/
|
|
||||||
touch() {
|
|
||||||
this.lastAccess = Date.now();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if playlist exists and is ready
|
|
||||||
*/
|
|
||||||
async isPlaylistReady() {
|
|
||||||
try {
|
|
||||||
await fs.access(this.playlistPath);
|
|
||||||
const content = await fs.readFile(this.playlistPath, 'utf8');
|
|
||||||
// Check if playlist has at least one segment
|
|
||||||
return content.includes('.ts');
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wait for playlist to be ready (with timeout)
|
|
||||||
* Fast-fails if FFmpeg exited with an error before the timeout.
|
|
||||||
*/
|
|
||||||
async waitForPlaylist(timeoutMs = 10000) {
|
|
||||||
const startTime = Date.now();
|
|
||||||
while (Date.now() - startTime < timeoutMs) {
|
|
||||||
if (await this.isPlaylistReady()) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
// Fast-fail: FFmpeg already exited with an error — no point waiting
|
|
||||||
if (this.status === 'error' && !this.process) {
|
|
||||||
console.log(`[TranscodeSession ${this.id}] Fast-fail: FFmpeg exited with error, aborting wait`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 200));
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the HLS playlist content
|
|
||||||
*/
|
|
||||||
async getPlaylist() {
|
|
||||||
this.touch();
|
|
||||||
try {
|
|
||||||
return await fs.readFile(this.playlistPath, 'utf8');
|
|
||||||
} catch (err) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a specific segment
|
|
||||||
*/
|
|
||||||
async getSegment(segmentName) {
|
|
||||||
this.touch();
|
|
||||||
const segmentPath = path.join(this.dir, segmentName);
|
|
||||||
try {
|
|
||||||
await fs.access(segmentPath);
|
|
||||||
return segmentPath;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save session metadata to disk for recovery
|
|
||||||
*/
|
|
||||||
async persist() {
|
|
||||||
const metadata = {
|
|
||||||
id: this.id,
|
|
||||||
url: this.url,
|
|
||||||
status: this.status,
|
|
||||||
startTime: this.startTime,
|
|
||||||
lastAccess: this.lastAccess,
|
|
||||||
options: this.options,
|
|
||||||
seekOffset: this.options.seekOffset
|
|
||||||
};
|
|
||||||
const metaPath = path.join(this.dir, 'session.json');
|
|
||||||
await fs.writeFile(metaPath, JSON.stringify(metadata, null, 2));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Restore a session from disk metadata
|
|
||||||
*/
|
|
||||||
static async restore(sessionDir) {
|
|
||||||
const metaPath = path.join(sessionDir, 'session.json');
|
|
||||||
try {
|
|
||||||
const data = await fs.readFile(metaPath, 'utf8');
|
|
||||||
const metadata = JSON.parse(data);
|
|
||||||
const session = new TranscodeSession(metadata.url, metadata.options);
|
|
||||||
session.id = metadata.id;
|
|
||||||
session.dir = sessionDir;
|
|
||||||
session.playlistPath = path.join(sessionDir, 'stream.m3u8');
|
|
||||||
session.startTime = metadata.startTime;
|
|
||||||
session.lastAccess = metadata.lastAccess;
|
|
||||||
session.status = 'stopped'; // Not running after restart
|
|
||||||
return session;
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Failed to restore session from ${sessionDir}:`, err.message);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete session directory and all segments
|
|
||||||
*/
|
|
||||||
async cleanup() {
|
|
||||||
this.stop();
|
|
||||||
try {
|
|
||||||
await fs.rm(this.dir, { recursive: true, force: true });
|
|
||||||
console.log(`[TranscodeSession ${this.id}] Cleaned up session directory`);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[TranscodeSession ${this.id}] Failed to cleanup:`, err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Session Manager
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new transcode session
|
|
||||||
*/
|
|
||||||
async function createSession(url, options = {}) {
|
|
||||||
await ensureCacheDir();
|
|
||||||
const session = new TranscodeSession(url, options);
|
|
||||||
sessions.set(session.id, session);
|
|
||||||
return session;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get an existing session by ID
|
|
||||||
*/
|
|
||||||
function getSession(sessionId) {
|
|
||||||
const session = sessions.get(sessionId);
|
|
||||||
if (session) {
|
|
||||||
session.touch();
|
|
||||||
}
|
|
||||||
return session;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get or create a session for a URL (reuses existing if still valid)
|
|
||||||
*/
|
|
||||||
async function getOrCreateSession(url, options = {}) {
|
|
||||||
// Check for existing session with same URL
|
|
||||||
for (const session of sessions.values()) {
|
|
||||||
if (session.url === url && session.status === 'running') {
|
|
||||||
session.touch();
|
|
||||||
return session;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Create new session
|
|
||||||
return createSession(url, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop and remove a session
|
|
||||||
*/
|
|
||||||
async function removeSession(sessionId) {
|
|
||||||
const session = sessions.get(sessionId);
|
|
||||||
if (session) {
|
|
||||||
await session.cleanup();
|
|
||||||
sessions.delete(sessionId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cleanup stale sessions (idle for too long)
|
|
||||||
*/
|
|
||||||
async function cleanupStaleSessions() {
|
|
||||||
const now = Date.now();
|
|
||||||
for (const [id, session] of sessions) {
|
|
||||||
if (now - session.lastAccess > SESSION_TIMEOUT_MS) {
|
|
||||||
console.log(`[TranscodeSession] Cleaning up stale session ${id}`);
|
|
||||||
await removeSession(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Recover sessions from disk after server restart
|
|
||||||
*/
|
|
||||||
async function recoverSessions() {
|
|
||||||
try {
|
|
||||||
await fs.access(CACHE_DIR);
|
|
||||||
const dirs = await fs.readdir(CACHE_DIR, { withFileTypes: true });
|
|
||||||
|
|
||||||
for (const dirent of dirs) {
|
|
||||||
if (dirent.isDirectory()) {
|
|
||||||
const sessionDir = path.join(CACHE_DIR, dirent.name);
|
|
||||||
const session = await TranscodeSession.restore(sessionDir);
|
|
||||||
if (session) {
|
|
||||||
sessions.set(session.id, session);
|
|
||||||
console.log(`[TranscodeSession] Recovered session ${session.id}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
// Cache dir doesn't exist yet, that's fine
|
|
||||||
if (err.code !== 'ENOENT') {
|
|
||||||
console.error('[TranscodeSession] Error recovering sessions:', err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start cleanup interval
|
|
||||||
*/
|
|
||||||
let cleanupInterval = null;
|
|
||||||
function startCleanupInterval() {
|
|
||||||
if (!cleanupInterval) {
|
|
||||||
cleanupInterval = setInterval(cleanupStaleSessions, CLEANUP_INTERVAL_MS);
|
|
||||||
cleanupInterval.unref(); // Don't prevent process exit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all active sessions (for debugging/monitoring)
|
|
||||||
*/
|
|
||||||
function getAllSessions() {
|
|
||||||
return Array.from(sessions.values()).map(s => ({
|
|
||||||
id: s.id,
|
|
||||||
url: s.url,
|
|
||||||
status: s.status,
|
|
||||||
startTime: s.startTime,
|
|
||||||
lastAccess: s.lastAccess,
|
|
||||||
idleMs: Date.now() - s.lastAccess
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
TranscodeSession,
|
|
||||||
createSession,
|
|
||||||
getSession,
|
|
||||||
getOrCreateSession,
|
|
||||||
removeSession,
|
|
||||||
cleanupStaleSessions,
|
|
||||||
recoverSessions,
|
|
||||||
startCleanupInterval,
|
|
||||||
getAllSessions,
|
|
||||||
CACHE_DIR,
|
|
||||||
SEGMENT_DURATION
|
|
||||||
};
|
|
||||||
@@ -18,7 +18,6 @@ const fs = require('fs').promises;
|
|||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const EventEmitter = require('events');
|
const EventEmitter = require('events');
|
||||||
const hwDetect = require('./hwDetect');
|
const hwDetect = require('./hwDetect');
|
||||||
const streamInput = require('../utils/streamInput');
|
|
||||||
|
|
||||||
// Session storage
|
// Session storage
|
||||||
const sessions = new Map();
|
const sessions = new Map();
|
||||||
@@ -27,9 +26,9 @@ const sessions = new Map();
|
|||||||
const CACHE_DIR = path.join(process.cwd(), 'transcode-cache');
|
const CACHE_DIR = path.join(process.cwd(), 'transcode-cache');
|
||||||
|
|
||||||
// Session settings
|
// Session settings
|
||||||
const SESSION_TIMEOUT_MS = 3 * 60 * 1000; // 3 minutes idle timeout (kills orphaned sessions quickly)
|
const SESSION_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes idle timeout
|
||||||
const SEGMENT_DURATION = 4; // seconds per HLS segment
|
const SEGMENT_DURATION = 4; // seconds per HLS segment
|
||||||
const CLEANUP_INTERVAL_MS = 60 * 1000; // Check every 60 seconds
|
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // Check every 5 minutes
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a unique session ID
|
* Generate a unique session ID
|
||||||
@@ -182,17 +181,11 @@ class TranscodeSession extends EventEmitter {
|
|||||||
console.log(`[TranscodeSession ${this.id}] Auto encoder resolved to: ${encoder}`);
|
console.log(`[TranscodeSession ${this.id}] Auto encoder resolved to: ${encoder}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sizing = streamInput.probeSizingForUrl(this.url);
|
|
||||||
const referer = streamInput.deriveRefererFromUrl(this.url);
|
|
||||||
|
|
||||||
const args = [
|
const args = [
|
||||||
'-hide_banner',
|
'-hide_banner',
|
||||||
'-loglevel', 'warning',
|
'-loglevel', 'warning',
|
||||||
'-user_agent', this.options.userAgent,
|
'-user_agent', this.options.userAgent,
|
||||||
];
|
];
|
||||||
if (referer) {
|
|
||||||
args.push('-referer', referer);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add hardware acceleration input options based on encoder (only if encoding)
|
// Add hardware acceleration input options based on encoder (only if encoding)
|
||||||
if (videoMode === 'encode') {
|
if (videoMode === 'encode') {
|
||||||
@@ -201,8 +194,8 @@ class TranscodeSession extends EventEmitter {
|
|||||||
|
|
||||||
// Input options (common)
|
// Input options (common)
|
||||||
args.push(
|
args.push(
|
||||||
'-probesize', sizing.probesize,
|
'-probesize', '5000000',
|
||||||
'-analyzeduration', sizing.analyzeduration,
|
'-analyzeduration', '5000000',
|
||||||
'-fflags', '+genpts+discardcorrupt',
|
'-fflags', '+genpts+discardcorrupt',
|
||||||
'-err_detect', 'ignore_err',
|
'-err_detect', 'ignore_err',
|
||||||
'-reconnect', '1',
|
'-reconnect', '1',
|
||||||
@@ -229,10 +222,7 @@ class TranscodeSession extends EventEmitter {
|
|||||||
if (this.options.videoCodec === 'hevc' || this.options.videoCodec === 'h265') {
|
if (this.options.videoCodec === 'hevc' || this.options.videoCodec === 'h265') {
|
||||||
args.push('-bsf:v', 'hevc_mp4toannexb');
|
args.push('-bsf:v', 'hevc_mp4toannexb');
|
||||||
} else if (this.options.videoCodec === 'h264' || this.options.videoCodec === 'avc') {
|
} else if (this.options.videoCodec === 'h264' || this.options.videoCodec === 'avc') {
|
||||||
// Keep Annex-B conversion and force parameter sets to be repeated.
|
args.push('-bsf:v', 'h264_mp4toannexb');
|
||||||
// This helps players recover on streams where SPS/PPS appear sporadically,
|
|
||||||
// which often manifests as "audio OK, black video".
|
|
||||||
args.push('-bsf:v', 'h264_mp4toannexb,dump_extra');
|
|
||||||
} else {
|
} else {
|
||||||
// Fallback (e.g. unknown codec), try strict extraction
|
// Fallback (e.g. unknown codec), try strict extraction
|
||||||
args.push('-bsf:v', 'dump_extra');
|
args.push('-bsf:v', 'dump_extra');
|
||||||
@@ -306,12 +296,12 @@ class TranscodeSession extends EventEmitter {
|
|||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case 'vaapi':
|
case 'vaapi':
|
||||||
// Solo registramos el dispositivo VAAPI globalmente.
|
// VAAPI hardware decoding (Linux)
|
||||||
// Se usa decodificación SOFTWARE para máxima compatibilidad con
|
args.push(
|
||||||
// cualquier codec de entrada (HEVC, VP9, etc.) — el GPU Intel
|
'-hwaccel', 'vaapi',
|
||||||
// puede no tener soporte de decodificación para todos ellos.
|
'-hwaccel_device', '/dev/dri/renderD128',
|
||||||
// El upload a memoria VAAPI se hace en el filtro de vídeo.
|
'-hwaccel_output_format', 'vaapi'
|
||||||
args.push('-vaapi_device', '/dev/dri/renderD128');
|
);
|
||||||
break;
|
break;
|
||||||
case 'qsv':
|
case 'qsv':
|
||||||
// Intel QuickSync hardware decoding
|
// Intel QuickSync hardware decoding
|
||||||
@@ -414,9 +404,7 @@ class TranscodeSession extends EventEmitter {
|
|||||||
// Force nv12 (8-bit) output to handle 10-bit inputs (fixes "10 bit encode not supported")
|
// Force nv12 (8-bit) output to handle 10-bit inputs (fixes "10 bit encode not supported")
|
||||||
return `scale_cuda=-2:${height}:interp_algo=lanczos:format=nv12`;
|
return `scale_cuda=-2:${height}:interp_algo=lanczos:format=nv12`;
|
||||||
case 'vaapi':
|
case 'vaapi':
|
||||||
// Software decode → scale → nv12 → hwupload → VAAPI encode
|
return `scale_vaapi=w=-2:h=${height}:format=nv12`;
|
||||||
// format=nv12 ANTES de hwupload es obligatorio para Intel iGPU en LXC
|
|
||||||
return `scale=-2:${height},format=nv12,hwupload`;
|
|
||||||
case 'qsv':
|
case 'qsv':
|
||||||
return `scale_qsv=w=-2:h=${height}:format=nv12`;
|
return `scale_qsv=w=-2:h=${height}:format=nv12`;
|
||||||
case 'amf':
|
case 'amf':
|
||||||
@@ -478,11 +466,14 @@ class TranscodeSession extends EventEmitter {
|
|||||||
// The format is handled automatically when using -hwaccel_output_format vaapi
|
// The format is handled automatically when using -hwaccel_output_format vaapi
|
||||||
args.push('-vf', this.buildScaleFilter('vaapi', height));
|
args.push('-vf', this.buildScaleFilter('vaapi', height));
|
||||||
|
|
||||||
// VAAPI encoder. No especificar -profile:v ni -pix_fmt (los gestiona el driver).
|
// VAAPI encoder with quality setting
|
||||||
// -bf 3 puede causar problemas en algunos drivers Intel; se omite.
|
// Note: -global_quality is the portable way to set quality for VAAPI
|
||||||
args.push(
|
args.push(
|
||||||
'-c:v', 'h264_vaapi',
|
'-c:v', 'h264_vaapi',
|
||||||
'-global_quality', String(qp)
|
'-profile:v', 'main', // Use main profile for compatibility
|
||||||
|
'-global_quality', String(qp),
|
||||||
|
'-bf', '3',
|
||||||
|
'-pix_fmt', 'yuv420p' // Force 8-bit output for compatibility
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -560,7 +551,6 @@ class TranscodeSession extends EventEmitter {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Wait for playlist to be ready (with timeout)
|
* Wait for playlist to be ready (with timeout)
|
||||||
* Fast-fails if FFmpeg exited with an error before the timeout.
|
|
||||||
*/
|
*/
|
||||||
async waitForPlaylist(timeoutMs = 10000) {
|
async waitForPlaylist(timeoutMs = 10000) {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
@@ -568,11 +558,6 @@ class TranscodeSession extends EventEmitter {
|
|||||||
if (await this.isPlaylistReady()) {
|
if (await this.isPlaylistReady()) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// Fast-fail: FFmpeg already exited with an error — no point waiting
|
|
||||||
if (this.status === 'error' && !this.process) {
|
|
||||||
console.log(`[TranscodeSession ${this.id}] Fast-fail: FFmpeg exited with error, aborting wait`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 200));
|
await new Promise(resolve => setTimeout(resolve, 200));
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Opciones de entrada HTTP para ffprobe/ffmpeg con orígenes tipo Xtream / Jellyfin (Dispatcharr).
|
|
||||||
* Muchos backends exigen Referer/origin coherentes además del User-Agent.
|
|
||||||
*/
|
|
||||||
|
|
||||||
function deriveRefererFromUrl(inputUrl) {
|
|
||||||
try {
|
|
||||||
const u = new URL(inputUrl);
|
|
||||||
return `${u.origin}/`;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** VOD pesado (Remux Jellyfin MKV/MP4, GOP largo) — probes más grandes. */
|
|
||||||
function isHeavyVodUrl(url) {
|
|
||||||
if (!url || typeof url !== 'string') return false;
|
|
||||||
const u = url.toLowerCase();
|
|
||||||
if (u.includes('/movie/') || u.includes('/series/')) return true;
|
|
||||||
if (/\.mkv\b/i.test(url) || /\.mp4\b/i.test(url)) return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function probeSizingForUrl(url) {
|
|
||||||
if (isHeavyVodUrl(url)) {
|
|
||||||
/** Microseconds; VOD Jellyfin/remux pueden necesitar leer bastante antes del analyze */
|
|
||||||
return { probesize: '32000000', analyzeduration: '45000000', heavy: true };
|
|
||||||
}
|
|
||||||
return { probesize: '8000000', analyzeduration: '8000000', heavy: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
deriveRefererFromUrl,
|
|
||||||
isHeavyVodUrl,
|
|
||||||
probeSizingForUrl
|
|
||||||
};
|
|
||||||
Reference in New Issue
Block a user