10 Commits

Author SHA1 Message Date
Joaquin bb189a472e Instancia transcoder t1: cambios locales 2026-07-13 15:19:13 +00:00
Technomancer702 0e26a90dae Merge pull request #95 from okynorch/doc-update
Updated readme
2026-03-02 15:10:21 -08:00
fry f3f9d79e8a init 2026-02-16 09:39:42 -08:00
Technomancer702 50d337f87d chore: update plugin readme 2026-02-05 01:46:10 -08:00
Technomancer702 de41aa04fd feat: enhance plugin system with async support and lifecycle hooks & reverted PR #79 due to Node.js v24 stream compatibility issues 2026-02-05 01:42:58 -08:00
Technomancer702 72e14dca57 feat: enhance plugin system with async support and lifecycle hooks & reverted PR #79 due to Node.js v24 stream compatibility issues 2026-02-05 01:42:40 -08:00
Technomancer702 6eafa871b5 Merge pull request #82 from leo3438/main
feat: add modular plugin system and developer documentation
2026-02-04 15:46:10 -08:00
Technomancer702 31aafe4d37 Merge pull request #79 from avandeweghe/patch-1
Refactor parseStreaming to use pendingBatches array
2026-02-04 15:45:56 -08:00
alphaone 146f7dd1b1 feat: add modular plugin system and documentation 2026-02-04 10:48:36 +00:00
avandeweghe bfc80738f4 Refactor parseStreaming to use pendingBatches array
Was running into an issue where epg wouldn't process passed the first batch. Refactored the batching to use an array and added backpressure to keep memory usage low.
2026-01-31 19:31:03 -05:00
31 changed files with 3305 additions and 498 deletions
+10
View File
@@ -0,0 +1,10 @@
# 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
+2 -2
View File
@@ -46,7 +46,7 @@ nodecast-tv is a modern, web-based IPTV player featuring Live TV, EPG, Movies (V
1. Clone the repository:
```bash
git clone https://github.com/yourusername/nodecast-tv.git
git clone https://github.com/technomancer702/nodecast-tv.git
cd nodecast-tv
```
@@ -114,7 +114,7 @@ Ensure you have the [NVIDIA Container Toolkit](https://docs.nvidia.com/datacente
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
capabilities: [gpu, utility, video, compute]
```
**Verify:**
+869
View File
@@ -0,0 +1,869 @@
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>
+4 -4
View File
@@ -1,12 +1,12 @@
{
"name": "nodecast-tv",
"version": "2.1.4",
"name": "kiratv",
"version": "2.1.4-kira1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nodecast-tv",
"version": "2.1.4",
"name": "kiratv",
"version": "2.1.4-kira1",
"license": "GPL-3.0-only",
"dependencies": {
"@ffprobe-installer/ffprobe": "^2.1.2",
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "nodecast-tv",
"version": "2.1.4",
"description": "Modern IPTV Player",
"name": "kiratv",
"version": "2.1.4-kira1",
"description": "KiraTV — reproductor IPTV para el navegador (fork de NodeCast)",
"license": "GPL-3.0-only",
"main": "server/index.js",
"scripts": {
+109 -42
View File
@@ -1,5 +1,5 @@
/* =====================================================
* NodeCast TV - Dark Theme Design System
* KiraTV — tema oscuro (azul marino + oro)
===================================================== */
/* CSS Variables */
@@ -7,32 +7,37 @@
/* iOS Safari toolbar compensation (set dynamically by JS) */
--ios-ui-bottom: 0px;
/* Brand — logo hero (marino #000066 / blanco; acento arena para botones) */
--color-brand-navy: #000066;
--color-brand-navy-mid: #050045;
--color-accent: #e8d5b5;
--color-accent-hover: #f5ead8;
--color-accent-muted: rgba(232, 213, 181, 0.14);
--color-on-accent: #000044;
/* Colors */
--color-bg-primary: #0a0a0f;
--color-bg-secondary: #12121a;
--color-bg-tertiary: #1a1a25;
--color-bg-hover: #22222f;
--color-bg-active: #2a2a3a;
--color-bg-primary: #000022;
--color-bg-secondary: #050038;
--color-bg-tertiary: #0a0a4d;
--color-bg-hover: #12125c;
--color-bg-active: #1a1a72;
--color-accent: #6366f1;
--color-accent-hover: #818cf8;
--color-accent-dim: rgba(99, 102, 241, 0.2);
--color-accent-dim: var(--color-accent-muted);
--color-success: #10b981;
--color-warning: #f59e0b;
--color-error: #ef4444;
--color-success: #34d399;
--color-warning: #fbbf24;
--color-error: #f87171;
--color-text-primary: #f1f1f5;
--color-text-secondary: #a1a1aa;
--color-text-muted: #71717a;
--color-text-primary: #f4f6fb;
--color-text-secondary: #a8b0c4;
--color-text-muted: #6b7389;
--color-border: #27272a;
--color-border-light: #3f3f46;
--color-border: #243047;
--color-border-light: #344062;
/* Glass effect */
--glass-bg: rgba(18, 18, 26, 0.8);
--glass-border: rgba(255, 255, 255, 0.1);
--glass-bg: rgba(0, 0, 102, 0.88);
--glass-border: rgba(255, 255, 255, 0.08);
/* Spacing */
--space-xs: 4px;
@@ -52,7 +57,7 @@
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
--shadow-glow: 0 0 20px rgba(99, 102, 241, 0.3);
--shadow-glow: 0 4px 28px rgba(232, 213, 181, 0.16);
/* Transitions */
--transition-fast: 150ms ease;
@@ -83,11 +88,14 @@
html,
body {
height: 100%;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-family: 'DM Sans', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
line-height: 1.5;
color: var(--color-text-primary);
background: var(--color-bg-primary);
background:
radial-gradient(ellipse 120% 85% at 50% -30%, rgba(255, 255, 255, 0.04), transparent 52%),
radial-gradient(ellipse 90% 55% at 100% 35%, rgba(0, 0, 102, 0.45), transparent),
linear-gradient(185deg, var(--color-brand-navy) 0%, var(--color-bg-primary) 55%);
overflow: hidden;
/* Prevent pull-to-refresh and overscroll bounce on mobile */
overscroll-behavior: none;
@@ -137,6 +145,9 @@ h6 {
h2 {
font-size: 1.5rem;
font-family: 'Montserrat', 'DM Sans', sans-serif;
font-weight: 700;
letter-spacing: -0.02em;
}
h3 {
@@ -168,8 +179,9 @@ h4 {
padding-left: max(var(--space-lg), var(--safe-area-inset-left));
padding-right: max(var(--space-lg), var(--safe-area-inset-right));
background: var(--glass-bg);
backdrop-filter: blur(12px);
backdrop-filter: blur(14px);
border-bottom: 1px solid var(--glass-border);
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.06);
z-index: 100;
}
@@ -190,12 +202,24 @@ h4 {
fill: var(--color-accent);
}
/* Logo imagen de marca (PNG/JPEG) */
.navbar-logo-img {
display: block;
height: 42px;
width: auto;
max-width: 172px;
object-fit: contain;
object-position: left center;
border-radius: 10px;
box-shadow: 0 2px 14px rgba(0, 0, 0, 0.35);
}
.brand-text {
font-family: 'Century Gothic', sans-serif;
font-size: 1.5rem;
font-weight: 500;
letter-spacing: -0.03em;
color: #fff;
font-family: 'Montserrat', 'DM Sans', sans-serif;
font-size: 1.45rem;
font-weight: 600;
letter-spacing: -0.02em;
color: var(--color-text-primary);
/* Visual correction: shift text slightly down to align optically with icon center */
padding-top: 2px;
}
@@ -207,8 +231,8 @@ h4 {
.version-badge {
font-size: 0.65rem;
font-weight: 600;
background: var(--color-accent);
color: #fff;
background: linear-gradient(135deg, var(--color-accent) 0%, #c9a875 100%);
color: var(--color-on-accent);
padding: 2px 6px;
border-radius: 10px;
margin-left: 8px;
@@ -1492,13 +1516,15 @@ kbd {
}
.btn-primary {
background: var(--color-accent);
color: white;
background: linear-gradient(135deg, var(--color-accent) 0%, #c9a875 100%);
color: var(--color-on-accent);
font-weight: 600;
}
.btn-primary:hover {
background: var(--color-accent-hover);
background: linear-gradient(135deg, var(--color-accent-hover) 0%, var(--color-accent) 100%);
box-shadow: var(--shadow-glow);
color: var(--color-on-accent);
}
.btn-secondary {
@@ -4415,9 +4441,9 @@ kbd {
}
.user-badge-sso {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
color: #fff;
box-shadow: 0 2px 4px rgba(99, 102, 241, 0.3);
background: linear-gradient(135deg, var(--color-accent) 0%, #c9a875 100%);
color: var(--color-on-accent);
box-shadow: 0 2px 4px rgba(232, 213, 181, 0.22);
}
.user-badge-local {
@@ -4448,24 +4474,24 @@ kbd {
gap: 8px;
padding: 6px 14px;
margin-right: var(--space-md);
background: linear-gradient(135deg, var(--color-accent) 0%, #8b5cf6 100%);
background: linear-gradient(135deg, var(--color-accent) 0%, #c9a875 100%);
border-radius: var(--radius-full);
color: white;
color: var(--color-on-accent);
text-decoration: none;
font-size: 13px;
font-weight: 500;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.4);
box-shadow: 0 2px 10px rgba(232, 213, 181, 0.22);
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
animation: now-playing-pulse 2s ease-in-out infinite;
}
.now-playing-indicator:hover {
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.5);
box-shadow: 0 4px 14px rgba(232, 213, 181, 0.28);
}
.now-playing-indicator.hidden {
@@ -4819,4 +4845,45 @@ kbd {
font-size: 0.7rem;
max-width: 70px;
}
}
}
/* Electron Windows: mpv embutido (HWND) — ocultar overlays tras el vídeo nativo */
body.kiratv-win-embedded #player-controls-overlay,
body.kiratv-win-embedded #watch-overlay {
visibility: hidden !important;
}
/* WebView2 + panel vídeo Win32 — lista estrecha, reproductor fuera del motor web */
body.kiratv-shell-wv2:not(.kiratv-shell-wv2-fullweb) #page-live .player-section { display: none !important; }
body.kiratv-shell-wv2:not(.kiratv-shell-wv2-fullweb) #page-live .home-layout {
display: block;
}
body.kiratv-shell-wv2:not(.kiratv-shell-wv2-fullweb) #page-live .channel-sidebar {
width: 100% !important;
max-width: none !important;
}
body.kiratv-shell-wv2-playing #player-controls-overlay,
body.kiratv-shell-wv2-playing #watch-overlay {
visibility: hidden !important;
}
/* App de escritorio (Electron / WebView2): ancho completo; quita cajas centradas tipo max-width:1400px */
body.kiratv-desktop-app #app {
max-width: none;
width: 100%;
margin: 0;
}
body.kiratv-desktop-app .dashboard-content {
max-width: none !important;
width: 100%;
margin-left: 0 !important;
margin-right: 0 !important;
}
body.kiratv-desktop-app .settings-container {
max-width: none !important;
}
body.kiratv-desktop-app .watch-details-section {
max-width: none !important;
}
+10 -2
View File
@@ -1,3 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 187.63 210.6">
<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"/>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<defs>
<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>

Before

Width:  |  Height:  |  Size: 764 B

After

Width:  |  Height:  |  Size: 553 B

+162 -166
View File
@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en">
<html lang="es">
<head>
<meta charset="UTF-8">
@@ -7,14 +7,14 @@
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<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">
<title>KiraTV | Televisión en el navegador</title>
<meta name="description" content="TV en directo, películas y series con guía EPG — KiraTV">
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="stylesheet" href="css/main.css">
<link rel="apple-touch-icon" href="/img/kiratv-logo-hero.jpg">
<link rel="stylesheet" href="css/main.css?v=kira6">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Outfit:wght@800&display=swap"
rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&family=Montserrat:wght@500;600;700;800&display=swap" rel="stylesheet">
<!-- HLS.js for video playback -->
<script src="https://cdn.jsdelivr.net/npm/hls.js@1.5.7/dist/hls.min.js"></script>
</head>
@@ -24,15 +24,10 @@
<!-- Navigation -->
<nav class="navbar">
<div class="navbar-brand">
<span class="logo"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 187.63 210.6" fill="#6366f1"
class="icon">
<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" />
</svg></span>
<span class="brand-text">nodecast-tv</span>
<img src="/img/kiratv-logo-hero.jpg" alt="KiraTV" class="navbar-logo-img" width="176" height="44" decoding="async">
<span id="version-badge" class="version-badge"></span>
</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="Abrir o cerrar menú">
<span></span>
<span></span>
<span></span>
@@ -41,14 +36,14 @@
<!-- Now Playing indicator for movies/series -->
<a href="#" class="now-playing-indicator hidden" id="now-playing-indicator" data-page="watch">
<span class="now-playing-icon">▶</span>
<span class="now-playing-text" id="now-playing-text">Now Playing</span>
<span class="now-playing-text" id="now-playing-text">Reproduciendo</span>
</a>
<a href="#" class="nav-link active" data-page="home">
<span class="nav-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
class="icon">
<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z" />
</svg></span>
<span>Home</span>
<span>Inicio</span>
</a>
<a href="#" class="nav-link" data-page="live">
<span class="nav-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
@@ -56,14 +51,14 @@
<path
d="M21 6h-7.59l3.29-3.29L16 2l-4 4-4-4-.71.71L10.59 6H3a2 2 0 0 0-2 2v12c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V8a2 2 0 0 0-2-2zm0 14H3V8h18v12zM9 10v8l7-4z" />
</svg></span>
<span>Live TV</span>
<span>TV en directo</span>
</a>
<a href="#" class="nav-link" data-page="guide">
<span class="nav-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
class="icon">
<path d="M3 13h2v-2H3v2zm0 4h2v-2H3v2zm0-8h2V7H3v2zm4 4h14v-2H7v2zm0 4h14v-2H7v2zM7 7v2h14V7H7z" />
</svg></span>
<span>TV Guide</span>
<span>Guía TV</span>
</a>
<a href="#" class="nav-link" data-page="movies">
<span class="nav-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
@@ -71,7 +66,7 @@
<path
d="M18 4l2 4h-3l-2-4h-2l2 4h-3l-2-4H8l2 4H7L5 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h20c1.1 0 2-.9 2-2V4h-4z" />
</svg></span>
<span>Movies</span>
<span>Películas</span>
</a>
<a href="#" class="nav-link" data-page="series">
<span class="nav-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
@@ -87,7 +82,7 @@
<path
d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L3.16 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.04.64.09.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.58 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" />
</svg></span>
<span>Settings</span>
<span>Ajustes</span>
</a>
</div>
</nav>
@@ -97,7 +92,7 @@
<!-- Home (Dashboard) Page -->
<div id="page-home" class="page active">
<!-- Content will be rendered by HomePage.js -->
<div class="loading-state">Loading Dashboard...</div>
<div class="loading-state">Cargando inicio…</div>
</div>
<!-- Live TV Page (formerly Home) -->
@@ -108,14 +103,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon">
<path d="M3 13h2v-2H3v2zm0 4h2v-2H3v2zm0-8h2V7H3v2zm4 4h14v-2H7v2zm0 4h14v-2H7v2zM7 7v2h14V7H7z" />
</svg>
<span>Channels</span>
<span>Canales</span>
</button>
<!-- Overlay for drawer -->
<div class="channel-sidebar-overlay" id="channel-sidebar-overlay"></div>
<!-- Expand button (visible when sidebar collapsed on desktop) -->
<button class="sidebar-expand-btn" id="sidebar-expand-btn" title="Expand sidebar">
<button class="sidebar-expand-btn" id="sidebar-expand-btn" title="Mostrar panel de canales">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon">
<path d="M8.59 16.59L10 18l6-6-6-6-1.41 1.41L13.17 12z" />
</svg>
@@ -126,11 +121,11 @@
<div class="sidebar-header">
<div class="sidebar-header-row">
<div class="search-wrapper">
<input type="text" id="channel-search" placeholder="Search channels..." class="search-input">
<button type="button" class="search-clear" title="Clear search">&times;</button>
<input type="text" id="channel-search" placeholder="Buscar canales…" class="search-input">
<button type="button" class="search-clear" title="Limpiar búsqueda">&times;</button>
</div>
<button id="sidebar-collapse-btn" class="btn btn-sm btn-ghost sidebar-collapse-btn"
title="Collapse sidebar">
title="Ocultar panel">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon">
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z" />
</svg>
@@ -138,9 +133,9 @@
</div>
<div class="sidebar-controls">
<select id="source-select" class="source-select">
<option value="">All Sources</option>
<option value="">Todas las fuentes</option>
</select>
<button id="toggle-groups" class="btn btn-sm btn-ghost" title="Expand/Collapse All">
<button id="toggle-groups" class="btn btn-sm btn-ghost" title="Expandir o plegar todo">
<span><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon">
<path
d="M12 5.83L15.17 9l1.41-1.41L12 3 7.41 7.59 8.83 9 12 5.83zm0 12.34L8.83 15l-1.41 1.41L12 21l4.59-4.59L15.17 15 12 18.17z" />
@@ -151,8 +146,8 @@
</div>
<div id="channel-list" class="channel-list">
<div class="empty-state">
<p>No channels loaded</p>
<p class="hint">Add a source in Settings to get started</p>
<p>Cargando canales…</p>
<p class="hint">Las categorías vienen plegadas: pulsa un nombre de grupo para ver los canales. Puedes usar «Expandir/plegar todo» arriba.</p>
</div>
</div>
</aside>
@@ -225,9 +220,9 @@
</svg>
</button>
<div class="watch-captions-menu hidden" id="player-captions-menu">
<div class="captions-menu-title">Subtitles</div>
<div class="captions-menu-title">Subtítulos</div>
<div class="captions-menu-list" id="player-captions-list">
<button class="captions-option active" data-index="-1">Off</button>
<button class="captions-option active" data-index="-1">Desactivados</button>
</div>
</div>
</div>
@@ -257,7 +252,7 @@
<path
d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z" />
</svg>
Copy Stream URL
Copiar URL del stream
</button>
</div>
</div>
@@ -267,18 +262,18 @@
<div class="player-overlay" id="player-overlay">
<div class="overlay-content">
<p>Select a channel to start watching</p>
<p>Elige un canal para empezar</p>
</div>
</div>
<!-- Now Playing Overlay -->
<div class="now-playing-overlay hidden" id="now-playing">
<div class="now-playing-info">
<h3 class="channel-name">No channel selected</h3>
<h3 class="channel-name">Ningún canal seleccionado</h3>
<p class="program-title"></p>
<p class="program-time"></p>
</div>
<div class="up-next">
<h4>Up Next</h4>
<h4>A continuación</h4>
<ul id="up-next-list"></ul>
</div>
</div>
@@ -290,24 +285,24 @@
<!-- Guide Page -->
<div id="page-guide" class="page">
<div class="guide-header">
<h2>TV Guide</h2>
<h2>Guía TV</h2>
<div class="guide-controls">
<select id="epg-group-select" class="source-select">
<option value="">All Groups</option>
<option value="">Todos los grupos</option>
</select>
<div class="search-wrapper">
<input type="text" id="epg-search" placeholder="Search channels..." class="search-input">
<button type="button" class="search-clear" title="Clear search">&times;</button>
<input type="text" id="epg-search" placeholder="Buscar canales…" class="search-input">
<button type="button" class="search-clear" title="Limpiar búsqueda">&times;</button>
</div>
<button id="guide-prev" class="btn btn-ghost">&larr; Earlier</button>
<span id="guide-date" class="guide-date">Today</span>
<button id="guide-next" class="btn btn-ghost">Later &rarr;</button>
<button id="guide-prev" class="btn btn-ghost">&larr; Antes</button>
<span id="guide-date" class="guide-date">Hoy</span>
<button id="guide-next" class="btn btn-ghost">Después &rarr;</button>
</div>
</div>
<div id="epg-grid" class="epg-grid">
<div class="empty-state">
<p>No EPG data available</p>
<p class="hint">Add an EPG source in Settings</p>
<p>No hay datos de guía (EPG)</p>
<p class="hint">Añade una fuente EPG en Ajustes</p>
</div>
</div>
</div>
@@ -315,29 +310,29 @@
<!-- Movies Page -->
<div id="page-movies" class="page">
<div class="movies-header">
<h2>Movies</h2>
<h2>Películas</h2>
<div class="movies-controls">
<select id="movies-source-select" class="source-select">
<option value="">All Sources</option>
<option value="">Todas las fuentes</option>
</select>
<select id="movies-category-select" class="source-select">
<option value="">All Categories</option>
<option value="">Todas las categorías</option>
</select>
<div class="search-wrapper">
<input type="text" id="movies-search" placeholder="Search movies..." class="search-input">
<button type="button" class="search-clear" title="Clear search">&times;</button>
<input type="text" id="movies-search" placeholder="Buscar películas…" class="search-input">
<button type="button" class="search-clear" title="Limpiar búsqueda">&times;</button>
</div>
<button id="movies-favorites-btn" class="btn btn-icon btn-ghost" title="Show Favorites Only">
<button id="movies-favorites-btn" class="btn btn-icon btn-ghost" title="Solo favoritos">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon">
<path
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
</svg> Favorites
</svg> Favoritos
</button>
</div>
</div>
<div id="movies-grid" class="movies-grid">
<div class="empty-state">
<p>Loading movies...</p>
<p>Cargando películas…</p>
</div>
</div>
</div>
@@ -348,20 +343,20 @@
<h2>Series</h2>
<div class="series-controls">
<select id="series-source-select" class="source-select">
<option value="">All Sources</option>
<option value="">Todas las fuentes</option>
</select>
<select id="series-category-select" class="source-select">
<option value="">All Categories</option>
<option value="">Todas las categorías</option>
</select>
<div class="search-wrapper">
<input type="text" id="series-search" placeholder="Search series..." class="search-input">
<button type="button" class="search-clear" title="Clear search">&times;</button>
<input type="text" id="series-search" placeholder="Buscar series…" class="search-input">
<button type="button" class="search-clear" title="Limpiar búsqueda">&times;</button>
</div>
<button id="series-favorites-btn" class="btn btn-icon btn-ghost" title="Show Favorites Only">
<button id="series-favorites-btn" class="btn btn-icon btn-ghost" title="Solo favoritos">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon">
<path
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
</svg> Favorites
</svg> Favoritos
</button>
</div>
</div>
@@ -369,12 +364,12 @@
<div class="series-content">
<div id="series-grid" class="series-grid">
<div class="empty-state">
<p>Loading series...</p>
<p>Cargando series…</p>
</div>
</div>
<!-- Series details panel (seasons/episodes) -->
<div id="series-details" class="series-details hidden">
<button class="series-back-btn">← Back to Series</button>
<button class="series-back-btn">← Volver a series</button>
<div class="series-details-header">
<img id="series-poster" src="" alt="" class="series-poster">
<div class="series-info">
@@ -390,59 +385,59 @@
<!-- Settings Page -->
<div id="page-settings" class="page">
<div class="settings-container">
<h2>Settings</h2>
<h2>Ajustes</h2>
<!-- Tabs -->
<div class="tabs">
<button class="tab active" data-tab="sources">Sources</button>
<button class="tab" data-tab="player">Player</button>
<button class="tab" data-tab="transcode">Transcoding</button>
<button class="tab" data-tab="content">Manage Content</button>
<button class="tab" data-tab="users" id="users-tab" style="display:none;">Users</button>
<button class="tab active" data-tab="sources">Fuentes</button>
<button class="tab" data-tab="player">Reproductor</button>
<button class="tab" data-tab="transcode">Transcodificación</button>
<button class="tab" data-tab="content">Contenido</button>
<button class="tab" data-tab="users" id="users-tab" style="display:none;">Usuarios</button>
</div>
<!-- Sources Tab -->
<div id="tab-sources" class="tab-content active">
<div class="source-section">
<div class="section-header">
<h3>Xtream Connections</h3>
<button class="btn btn-primary" id="add-xtream">+ Add Xtream</button>
<h3>Conexiones Xtream</h3>
<button class="btn btn-primary" id="add-xtream">+ Añadir Xtream</button>
</div>
<div id="xtream-list" class="source-list"></div>
</div>
<div class="source-section">
<div class="section-header">
<h3>M3U Playlists</h3>
<button class="btn btn-primary" id="add-m3u">+ Add M3U</button>
<h3>Listas M3U</h3>
<button class="btn btn-primary" id="add-m3u">+ Añadir M3U</button>
</div>
<div id="m3u-list" class="source-list"></div>
</div>
<div class="source-section">
<div class="section-header">
<h3>EPG Sources</h3>
<button class="btn btn-primary" id="add-epg">+ Add EPG</button>
<h3>Fuentes EPG</h3>
<button class="btn btn-primary" id="add-epg">+ Añadir EPG</button>
</div>
<div id="epg-list" class="source-list"></div>
</div>
<div class="source-section">
<h3>EPG Data Settings</h3>
<h3>Datos EPG</h3>
<div class="setting-item">
<label for="epg-refresh-interval">Auto-refresh EPG data every:</label>
<label for="epg-refresh-interval">Actualizar datos EPG automáticamente cada:</label>
<select id="epg-refresh-interval" class="form-input">
<option value="1">1 hour</option>
<option value="2">2 hours</option>
<option value="4">4 hours</option>
<option value="12">12 hours</option>
<option value="24" selected>24 hours</option>
<option value="0">Manual only</option>
<option value="1">1 hora</option>
<option value="2">2 horas</option>
<option value="4">4 horas</option>
<option value="12">12 horas</option>
<option value="24" selected>24 horas</option>
<option value="0">Solo manual</option>
</select>
</div>
<div class="setting-item">
<span class="setting-label">Last Refreshed:</span>
<span id="epg-last-refreshed" class="hint">Never</span>
<span class="setting-label">Última actualización:</span>
<span id="epg-last-refreshed" class="hint">Nunca</span>
</div>
<p class="hint">Use the <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
class="icon" style="width: 1em; height: 1em; vertical-align: middle;">
@@ -455,32 +450,32 @@
<!-- Player Tab -->
<div id="tab-player" class="tab-content">
<div class="settings-section">
<h3>Keyboard Controls</h3>
<h3>Teclado</h3>
<div class="setting-item">
<label class="setting-toggle">
<input type="checkbox" id="setting-arrow-keys" checked>
<span class="toggle-slider"></span>
</label>
<div class="setting-info">
<span class="setting-label">Arrow keys change channels</span>
<span class="setting-hint">When enabled, ↑/↓ change channels and ←/→ adjust volume</span>
<span class="setting-label">Las flechas cambian de canal</span>
<span class="setting-hint">Si está activo: ↑/↓ cambian canal y ←/→ ajustan volumen</span>
</div>
</div>
</div>
<div class="settings-section">
<h3>Now Playing Overlay</h3>
<h3>Overlay «Reproduciendo»</h3>
<div class="setting-item">
<label for="setting-overlay-duration">Display Duration (seconds)</label>
<label for="setting-overlay-duration">Duración en pantalla (segundos)</label>
<input type="number" id="setting-overlay-duration" class="form-input setting-number" min="1" max="30"
value="5">
</div>
</div>
<div class="settings-section">
<h3>Volume Settings</h3>
<h3>Volumen</h3>
<div class="setting-item">
<label for="setting-default-volume">Default Volume</label>
<label for="setting-default-volume">Volumen por defecto</label>
<div class="volume-slider-container">
<input type="range" id="setting-default-volume" class="volume-slider" min="0" max="100" value="80">
<span id="volume-value">80%</span>
@@ -492,36 +487,36 @@
<span class="toggle-slider"></span>
</label>
<div class="setting-info">
<span class="setting-label">Remember Last Volume</span>
<span class="setting-hint">When enabled, volume persists between sessions</span>
<span class="setting-label">Recordar último volumen</span>
<span class="setting-hint">Si está activo, el volumen se conserva entre sesiones</span>
</div>
</div>
</div>
<div class="settings-section">
<h3>Playback</h3>
<h3>Reproducción</h3>
<div class="setting-item">
<label class="setting-toggle">
<input type="checkbox" id="setting-autoplay-next">
<span class="toggle-slider"></span>
</label>
<div class="setting-info">
<span class="setting-label">Auto-play Next Episode</span>
<span class="setting-hint">Automatically play the next episode when one ends</span>
<span class="setting-label">Autoplay del siguiente episodio</span>
<span class="setting-hint">Cuando termine un episodio, pasa automáticamente al siguiente</span>
</div>
</div>
</div>
<div class="settings-section">
<h3>Keyboard Shortcuts</h3>
<h3>Atajos de teclado</h3>
<div class="shortcuts-grid">
<div class="shortcut"><kbd>↑</kbd> / <kbd>↓</kbd> Channel up/down (or volume)</div>
<div class="shortcut"><kbd>←</kbd> / <kbd>→</kbd> Volume down/up (when arrows = channels)</div>
<div class="shortcut"><kbd>Space</kbd> / <kbd>K</kbd> Play/Pause</div>
<div class="shortcut"><kbd>F</kbd> Fullscreen</div>
<div class="shortcut"><kbd>M</kbd> Mute/Unmute</div>
<div class="shortcut"><kbd>I</kbd> Toggle info overlay</div>
<div class="shortcut"><kbd>PgUp</kbd> / <kbd>PgDn</kbd> Channel up/down</div>
<div class="shortcut"><kbd>↑</kbd> / <kbd>↓</kbd> Canal anterior/siguiente (o volumen)</div>
<div class="shortcut"><kbd>←</kbd> / <kbd>→</kbd> Bajar/subir volumen (si flechas = canales)</div>
<div class="shortcut"><kbd>Espacio</kbd> / <kbd>K</kbd> Reproducir / Pausa</div>
<div class="shortcut"><kbd>F</kbd> Pantalla completa</div>
<div class="shortcut"><kbd>M</kbd> Silenciar / activar sonido</div>
<div class="shortcut"><kbd>I</kbd> Mostrar u ocultar información</div>
<div class="shortcut"><kbd>Re Pág</kbd> / <kbd>Av Pág</kbd> Canal anterior/siguiente</div>
</div>
</div>
</div>
@@ -529,14 +524,14 @@
<!-- Transcoding Tab -->
<div id="tab-transcode" class="tab-content">
<div class="settings-section">
<h3>Detected Hardware</h3>
<h3>Hardware detectado</h3>
<div id="hw-info-container" class="hw-info-container">
<p class="hint">Loading hardware information...</p>
<p class="hint">Cargando información del hardware…</p>
</div>
</div>
<div class="settings-section">
<h3>Encoder Settings</h3>
<h3>Codificador</h3>
<div class="setting-item">
<div class="setting-info" style="flex: 1;">
<span class="setting-label">Hardware Encoder</span>
@@ -678,12 +673,12 @@
<div class="setting-item">
<div class="setting-info" style="flex: 1;">
<span class="setting-label">Stream Output Format</span>
<span class="setting-hint">Container format for Xtream streams. Try TS if you experience buffering
issues.</span>
<span class="setting-hint">Para Dispatcharr: elige <strong>TS</strong> (aunque la URL lleve .m3u8, suele
ser video MPEG-TS; HLS falla en el navegador).</span>
</div>
<select id="setting-stream-format-tc" class="source-select" style="width: auto; min-width: 200px;">
<option value="m3u8">HLS (m3u8) - Recommended</option>
<option value="ts">MPEG-TS (ts) - Better buffer</option>
<option value="ts">MPEG-TS (ts) — recomendado con Dispatcharr</option>
<option value="m3u8">HLS (m3u8) — solo si el proveedor es manifiesto real</option>
</select>
</div>
</div>
@@ -734,30 +729,30 @@
<div class="content-browser">
<div class="content-browser-header">
<div class="content-type-toggle">
<button class="btn btn-sm active" id="content-type-channels">Channels</button>
<button class="btn btn-sm" id="content-type-movies">Movies</button>
<button class="btn btn-sm active" id="content-type-channels">Canales</button>
<button class="btn btn-sm" id="content-type-movies">Películas</button>
<button class="btn btn-sm" id="content-type-series">Series</button>
</div>
<select id="content-source-select" class="source-select">
<option value="">Select a source...</option>
<option value="">Selecciona una fuente…</option>
</select>
<div class="search-wrapper">
<input type="text" id="content-search" placeholder="Search groups/channels..." class="search-input">
<button type="button" class="search-clear" title="Clear search">&times;</button>
<input type="text" id="content-search" placeholder="Buscar grupos o canales…" class="search-input">
<button type="button" class="search-clear" title="Limpiar búsqueda">&times;</button>
</div>
<div class="content-actions">
<button class="btn btn-sm btn-secondary" id="content-show-all">Show All</button>
<button class="btn btn-sm btn-secondary" id="content-hide-all">Hide All</button>
<button class="btn btn-sm btn-secondary" id="content-show-all">Mostrar todo</button>
<button class="btn btn-sm btn-secondary" id="content-hide-all">Ocultar todo</button>
<button class="btn btn-sm btn-primary" id="content-save"><svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24" fill="currentColor" class="icon">
<path
d="M17 3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V7l-4-4zm-5 16c-1.66 0-3-1.34-3-3s1.34-3 3-3 3 1.34 3 3-1.34 3-3 3zm3-10H5V5h10v4z" />
</svg> Save Changes</button>
</svg> Guardar cambios</button>
</div>
</div>
<p class="hint" style="margin-bottom: var(--space-sm);">✓ Checked = Visible, ☐ Unchecked = Hidden</p>
<p class="hint" style="margin-bottom: var(--space-sm);">✓ Marcado = visible · ☐ Sin marcar = oculto</p>
<div id="content-tree" class="content-tree">
<p class="hint">Select a source to view groups and channels</p>
<p class="hint">Selecciona una fuente para ver grupos y canales</p>
</div>
</div>
</div>
@@ -765,47 +760,47 @@
<!-- Users Tab (Admin Only) -->
<div id="tab-users" class="tab-content">
<div class="settings-section">
<h3>User Management</h3>
<p class="hint">Manage user accounts and permissions</p>
<h3>Usuarios</h3>
<p class="hint">Gestiona cuentas y permisos</p>
<div class="user-list-container">
<table class="user-table">
<thead>
<tr>
<th>Username</th>
<th>Email</th>
<th>Role</th>
<th>Created</th>
<th>Actions</th>
<th>Usuario</th>
<th>Correo</th>
<th>Rol</th>
<th>Creado</th>
<th>Acciones</th>
</tr>
</thead>
<tbody id="user-list">
<tr>
<td colspan="4" class="hint">Loading users...</td>
<td colspan="4" class="hint">Cargando usuarios…</td>
</tr>
</tbody>
</table>
</div>
<div class="add-user-section">
<h4>Add New User</h4>
<h4>Añadir usuario</h4>
<form id="add-user-form" class="user-form">
<div class="form-group">
<label for="new-username">Username</label>
<label for="new-username">Usuario</label>
<input type="text" id="new-username" name="username" class="form-input" required>
</div>
<div class="form-group">
<label for="new-password">Password</label>
<label for="new-password">Contraseña</label>
<input type="password" id="new-password" name="password" class="form-input" required minlength="6">
</div>
<div class="form-group">
<label for="new-role">Role</label>
<label for="new-role">Rol</label>
<select id="new-role" name="role" class="form-input" required>
<option value="viewer">Viewer</option>
<option value="admin">Admin</option>
<option value="viewer">Espectador</option>
<option value="admin">Administrador</option>
</select>
</div>
<button type="submit" class="btn btn-primary">Add User</button>
<button type="submit" class="btn btn-primary">Añadir usuario</button>
</form>
</div>
</div>
@@ -823,7 +818,7 @@
<div class="watch-overlay" id="watch-overlay">
<!-- Top Bar: Back + Title -->
<div class="watch-top-bar vod-header">
<button class="watch-back-btn" id="watch-back-btn" title="Back">
<button class="watch-back-btn" id="watch-back-btn" title="Volver">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon">
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
</svg>
@@ -901,9 +896,9 @@
</svg>
</button>
<div class="watch-captions-menu hidden" id="watch-captions-menu">
<div class="captions-menu-title">Subtitles</div>
<div class="captions-menu-title">Subtítulos</div>
<div class="captions-menu-list" id="watch-captions-list">
<button class="captions-option active" data-index="-1">Off</button>
<button class="captions-option active" data-index="-1">Desactivados</button>
</div>
</div>
</div>
@@ -932,7 +927,7 @@
<path
d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z" />
</svg>
Copy Stream URL
Copiar URL del stream
</button>
</div>
</div>
@@ -941,7 +936,7 @@
<!-- Scroll Indicator -->
<div class="watch-scroll-hint" id="watch-scroll-hint">
<span>Scroll for details</span>
<span>Desplázate para ver detalles</span>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon">
<path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z" />
</svg>
@@ -951,12 +946,12 @@
<!-- Next Episode Panel -->
<div class="watch-next-episode hidden" id="watch-next-episode">
<div class="next-info">
<p class="next-label">Up Next</p>
<p class="next-label">Siguiente</p>
<h4 id="next-episode-title"></h4>
</div>
<span class="next-countdown" id="next-countdown">10</span>
<button class="btn btn-primary btn-sm" id="next-play-now">Play Now</button>
<button class="btn btn-ghost btn-sm" id="next-cancel">Cancel</button>
<button class="btn btn-primary btn-sm" id="next-play-now">Reproducir ya</button>
<button class="btn btn-ghost btn-sm" id="next-cancel">Cancelar</button>
</div>
</section>
@@ -978,7 +973,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon">
<path d="M8 5v14l11-7z" />
</svg>
<span id="watch-play-btn-text">Play</span>
<span id="watch-play-btn-text">Reproducir</span>
</button>
<button class="btn btn-ghost" id="watch-favorite-btn">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
@@ -991,7 +986,7 @@
<path
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
</svg>
<span>Favorite</span>
<span>Favorito</span>
</button>
</div>
</div>
@@ -999,7 +994,7 @@
<!-- Movies: Recommended Section -->
<div class="watch-recommended hidden" id="watch-recommended">
<h3>More Like This</h3>
<h3>Te puede gustar</h3>
<div class="watch-recommended-grid" id="watch-recommended-grid">
<!-- Movie cards inserted dynamically -->
</div>
@@ -1007,7 +1002,7 @@
<!-- Series: Seasons & Episodes -->
<div class="watch-episodes hidden" id="watch-episodes">
<h3>Episodes</h3>
<h3>Episodios</h3>
<div class="watch-seasons" id="watch-seasons">
<!-- Season accordions inserted dynamically -->
</div>
@@ -1022,7 +1017,7 @@
<div id="modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3 id="modal-title">Modal</h3>
<h3 id="modal-title">Ventana</h3>
<button class="modal-close">&times;</button>
</div>
<div id="modal-body" class="modal-body"></div>
@@ -1035,19 +1030,19 @@
<button class="context-item" data-action="play"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
fill="currentColor" class="icon">
<path d="M8 5v14l11-7z" />
</svg> Play</button>
</svg> Reproducir</button>
<button class="context-item" data-action="hide"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
fill="currentColor" class="icon">
<path
d="M12 7c2.76 0 5 2.24 5 5 0 .65-.13 1.26-.36 1.83l2.92 2.92c1.51-1.26 2.7-2.89 3.43-4.75-1.73-4.39-6-7.5-11-7.5-1.4 0-2.74.25-4.01.7l2.16 2.16C10.85 7.18 11.4 7 12 7zM2 4.27l2.28 2.28.46.46C3.08 8.3 1.78 10.02 1 12c1.73 4.39 6 7.5 11 7.5 1.55 0 3.03-.3 4.38-.84l.42.42L19.73 22 21 20.73 3.27 3 2 4.27zM7.53 9.8l1.55 1.55c-.05.21-.08.43-.08.65 0 1.66 1.34 3 3 3 .22 0 .44-.03.65-.08l1.55 1.55c-.67.33-1.41.53-2.2.53-2.76 0-5-2.24-5-5 0-.79.2-1.53.53-2.2zm4.31-.78l3.15 3.15.02-.16c0-1.66-1.34-3-3-3l-.17.01z" />
</svg> Hide</button>
</svg> Ocultar</button>
</div>
<!-- Edit User Modal -->
<div class="modal-overlay" id="edit-user-modal">
<div class="modal">
<div class="modal-header">
<h3 class="modal-title">Edit User</h3>
<h3 class="modal-title">Editar usuario</h3>
<button class="modal-close" id="edit-user-close">&times;</button>
</div>
<div class="modal-body">
@@ -1055,41 +1050,41 @@
<input type="hidden" id="edit-user-id">
<div class="modal-form-group">
<label for="edit-username">Username</label>
<label for="edit-username">Usuario</label>
<input type="text" id="edit-username" name="username" class="form-input" required>
</div>
<div class="modal-form-group">
<label for="edit-email">Email</label>
<input type="text" id="edit-email" class="form-input" readonly disabled placeholder="No email associated">
<label for="edit-email">Correo</label>
<input type="text" id="edit-email" class="form-input" readonly disabled placeholder="Sin correo asociado">
</div>
<div class="modal-form-group">
<label for="edit-role">Role</label>
<label for="edit-role">Rol</label>
<select id="edit-role" name="role" class="form-input" required>
<option value="viewer">Viewer</option>
<option value="admin">Admin</option>
<option value="viewer">Espectador</option>
<option value="admin">Administrador</option>
</select>
</div>
<div class="modal-form-group">
<label for="edit-password">New Password</label>
<label for="edit-password">Nueva contraseña</label>
<input type="password" id="edit-password" name="password" class="form-input"
placeholder="Leave blank to keep current">
<p class="hint" id="edit-password-hint" style="margin-top: 4px;">Optional. Leave blank to keep unchanged.
placeholder="Dejar en blanco para no cambiar">
<p class="hint" id="edit-password-hint" style="margin-top: 4px;">Opcional. Déjalo en blanco para mantener la actual.
</p>
</div>
<div class="modal-form-group hidden" id="oidc-info-group">
<label>OIDC Provider ID</label>
<label>ID de proveedor OIDC</label>
<code id="edit-oidc-id"
style="font-size: 0.8rem; color: var(--color-text-muted); padding: 4px; background: rgba(0,0,0,0.3); border-radius: 4px; display: block;"></code>
</div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" id="edit-user-cancel">Cancel</button>
<button class="btn btn-primary" id="edit-user-save">Save Changes</button>
<button class="btn btn-ghost" id="edit-user-cancel">Cancelar</button>
<button class="btn btn-primary" id="edit-user-save">Guardar cambios</button>
</div>
</div>
</div>
@@ -1097,7 +1092,8 @@
<!-- 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/desktopPlayback.js?v=9"></script>
<script src="/js/components/VideoPlayer.js?v=6"></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>
@@ -1106,9 +1102,9 @@
<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/pages/Settings.js?v=3"></script>
<script src="/js/pages/WatchPage.js?v=3"></script>
<script src="/js/app.js?v=5"></script>
<script>
// Check for SSO token in URL
(function () {
+1 -1
View File
@@ -136,7 +136,7 @@ const API = {
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}`),
getStreamUrl: (sourceId, streamId, type = 'live', container = 'm3u8') =>
getStreamUrl: (sourceId, streamId, type = 'live', container = 'ts') =>
API.request('GET', `/proxy/xtream/${sourceId}/stream/${streamId}/${type}?container=${container}`)
},
+7 -2
View File
@@ -1,5 +1,5 @@
/**
* NodeCast TV Application Entry Point
* KiraTV Application Entry Point
*/
class App {
@@ -150,6 +150,11 @@ class App {
// Initialize home page first (it's needed for channel list)
await this.pages.home.init();
// La barra lateral es global: antes solo se cargaban canales al abrir la página «Live»
// (LivePage.init no se llama en ningún sitio). Sin esto, en Inicio parece que «no hay canales».
await this.channelList.loadSources();
await this.channelList.loadChannels();
// Preload EPG data in background (non-blocking)
// This ensures EPG info is available on Live TV page without visiting Guide first
this.epgGuide.loadEpg().catch(err => {
@@ -161,7 +166,7 @@ class App {
const initialPage = hash && this.pages[hash] ? hash : 'home';
this.navigateTo(initialPage, true); // true = replace history (don't add)
console.log('NodeCast TV initialized');
console.log('KiraTV initialized');
}
async checkAuth() {
+3 -3
View File
@@ -46,7 +46,7 @@ class ChannelList {
*/
loadCollapsedState() {
try {
const saved = localStorage.getItem('nodecast_tv_collapsed_groups');
const saved = localStorage.getItem('kiratv_collapsed_groups');
if (saved) {
this.collapsedGroups = new Set(JSON.parse(saved));
this._hasCollapsedState = true;
@@ -64,7 +64,7 @@ class ChannelList {
*/
saveCollapsedState() {
try {
localStorage.setItem('nodecast_tv_collapsed_groups', JSON.stringify([...this.collapsedGroups]));
localStorage.setItem('kiratv_collapsed_groups', JSON.stringify([...this.collapsedGroups]));
} catch (err) {
console.error('Error saving collapsed state:', err);
}
@@ -1159,7 +1159,7 @@ class ChannelList {
let streamUrl;
if (channel.sourceType === 'xtream') {
// Get stream format from player settings (server-side) or fallback
const streamFormat = window.app?.player?.settings?.streamFormat || 'm3u8';
const streamFormat = window.app?.player?.settings?.streamFormat || 'ts';
const result = await API.proxy.xtream.getStreamUrl(channel.sourceId, channel.streamId, 'live', streamFormat);
streamUrl = result.url;
} else {
+99 -17
View File
@@ -8,6 +8,8 @@ class SourceManager {
this.xtreamList = document.getElementById('xtream-list');
this.m3uList = document.getElementById('m3u-list');
this.epgList = document.getElementById('epg-list');
/** @type {Record<number, string>} id → username (admin, for labels) */
this.userNamesById = {};
// Content browser state
this.contentType = 'channels'; // 'channels' or 'movies'
@@ -20,6 +22,29 @@ class SourceManager {
this.init();
}
/**
* Comprueba si la sesión es admin. Usa /api/auth/me si window.app.currentUser
* aún no está (carrera: el modal se abre antes de que termine checkAuth).
*/
async isAdminSession() {
let role = window.app?.currentUser?.role;
if (!role && typeof localStorage !== 'undefined') {
const token = localStorage.getItem('authToken');
if (token) {
try {
const r = await fetch('/api/auth/me', {
headers: { Authorization: `Bearer ${token}` }
});
if (r.ok) {
const u = await r.json();
role = u.role;
}
} catch (_) { /* ignore */ }
}
}
return String(role || '').toLowerCase() === 'admin';
}
init() {
// Add source buttons
document.getElementById('add-xtream').addEventListener('click', () => this.showAddModal('xtream'));
@@ -112,9 +137,20 @@ class SourceManager {
try {
const sources = await API.sources.getAll();
this.renderSourceList(this.xtreamList, sources.filter(s => s.type === 'xtream'), 'xtream');
this.renderSourceList(this.m3uList, sources.filter(s => s.type === 'm3u'), 'm3u');
this.renderSourceList(this.epgList, sources.filter(s => s.type === 'epg'), 'epg');
const admin = await this.isAdminSession();
this.userNamesById = {};
if (admin) {
try {
const users = await API.users.getAll();
users.forEach(u => { this.userNamesById[u.id] = u.username; });
} catch (e) {
console.warn('[SourceManager] Could not load users for labels:', e);
}
}
this.renderSourceList(this.xtreamList, sources.filter(s => s.type === 'xtream'), 'xtream', admin);
this.renderSourceList(this.m3uList, sources.filter(s => s.type === 'm3u'), 'm3u', admin);
this.renderSourceList(this.epgList, sources.filter(s => s.type === 'epg'), 'epg', admin);
} catch (err) {
console.error('Error loading sources:', err);
}
@@ -123,7 +159,7 @@ class SourceManager {
/**
* Render source list
*/
renderSourceList(container, sources, type) {
renderSourceList(container, sources, type, isAdmin = false) {
if (sources.length === 0) {
container.innerHTML = `<p class="hint">No ${type.toUpperCase()} sources configured</p>`;
return;
@@ -131,12 +167,21 @@ class SourceManager {
const icons = { xtream: Icons.live, m3u: Icons.guide, epg: Icons.series };
const esc = (t) => String(t ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
const admin = isAdmin;
const ownerLine = admin
? `<div class="source-meta hint">${source.ownerId != null
? `Propietario: ${esc(this.userNamesById[source.ownerId] || ('#' + source.ownerId))}`
: 'Fuente compartida (todos los usuarios)'}</div>`
: '';
container.innerHTML = sources.map(source => `
<div class="source-item ${source.enabled ? '' : 'disabled'}" data-id="${source.id}">
<span class="source-icon">${icons[type]}</span>
<div class="source-info">
<div class="source-name">${source.name}</div>
<div class="source-url">${source.url}</div>
<div class="source-name">${esc(source.name)}</div>
<div class="source-url">${esc(source.url)}</div>
${ownerLine}
</div>
<div class="source-actions">
<button class="btn btn-sm btn-secondary" data-action="refresh" title="Refresh Data">${Icons.refresh}</button>
@@ -165,7 +210,7 @@ class SourceManager {
/**
* Show add source modal
*/
showAddModal(type) {
async showAddModal(type) {
const modal = document.getElementById('modal');
const title = document.getElementById('modal-title');
const body = document.getElementById('modal-body');
@@ -174,7 +219,7 @@ class SourceManager {
const titles = { xtream: 'Add Xtream Connection', m3u: 'Add M3U Playlist', epg: 'Add EPG Source' };
title.textContent = titles[type];
body.innerHTML = this.getSourceForm(type);
body.innerHTML = await this.getSourceForm(type);
footer.innerHTML = `
<button class="btn btn-secondary" id="modal-cancel">Cancel</button>
@@ -202,7 +247,7 @@ class SourceManager {
const footer = document.getElementById('modal-footer');
title.textContent = `Edit ${type.toUpperCase()} Source`;
body.innerHTML = this.getSourceForm(type, source);
body.innerHTML = await this.getSourceForm(type, source);
footer.innerHTML = `
<button class="btn btn-secondary" id="modal-cancel">Cancel</button>
@@ -222,11 +267,32 @@ class SourceManager {
/**
* Get source form HTML
*/
getSourceForm(type, source = {}) {
async getSourceForm(type, source = {}) {
const esc = (t) => String(t ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
let ownerField = '';
if (await this.isAdminSession()) {
let users = [];
try {
users = await API.users.getAll();
} catch (e) {
console.warn('[SourceManager] users list:', e);
}
const opts = [`<option value="">Compartida (todos los usuarios autenticados)</option>`].concat(
users.map(u => `<option value="${u.id}" ${Number(source.ownerId) === Number(u.id) ? 'selected' : ''}>${esc(u.username)}</option>`)
);
ownerField = `
<div class="form-group">
<label for="source-owner">Propietario</label>
<select id="source-owner" class="form-input">${opts.join('')}</select>
<p class="hint">Si asignas un usuario, solo él podrá usar esta fuente en el proxy (evita doble consumo de conexiones XC).</p>
</div>`;
}
const nameField = `
<div class="form-group">
<label for="source-name">Name</label>
<input type="text" id="source-name" class="form-input" placeholder="My Source" value="${source.name || ''}">
<input type="text" id="source-name" class="form-input" placeholder="My Source" value="${esc(source.name)}">
</div>
`;
@@ -235,27 +301,28 @@ class SourceManager {
<label for="source-url">${type === 'xtream' ? 'Server URL' : 'URL'}</label>
<input type="text" id="source-url" class="form-input"
placeholder="${type === 'xtream' ? 'http://server.com:port' : 'https://example.com/playlist.m3u'}"
value="${source.url || ''}">
value="${esc(source.url)}">
</div>
`;
if (type === 'xtream') {
const pwVal = source.password && !String(source.password).includes('•') ? esc(source.password) : '';
return `
${ownerField}
${nameField}
${urlField}
<div class="form-group">
<label for="source-username">Username</label>
<input type="text" id="source-username" class="form-input" value="${source.username || ''}">
<input type="text" id="source-username" class="form-input" value="${esc(source.username)}">
</div>
<div class="form-group">
<label for="source-password">Password</label>
<input type="password" id="source-password" class="form-input"
value="${source.password && !source.password.includes('•') ? source.password : ''}">
<input type="password" id="source-password" class="form-input" value="${pwVal}">
</div>
`;
}
return nameField + urlField;
return ownerField + nameField + urlField;
}
/**
@@ -295,7 +362,15 @@ class SourceManager {
}
}
await API.sources.create({ type, name, url, username, password });
const payload = { type, name, url, username, password };
if (await this.isAdminSession()) {
const sel = document.getElementById('source-owner');
if (sel) {
const v = sel.value;
payload.ownerId = v === '' ? null : parseInt(v, 10);
}
}
await API.sources.create(payload);
document.getElementById('modal').classList.remove('active');
await this.loadSources();
@@ -329,6 +404,13 @@ class SourceManager {
data.username = username;
if (password) data.password = password;
}
if (await this.isAdminSession()) {
const sel = document.getElementById('source-owner');
if (sel) {
const v = sel.value;
data.ownerId = v === '' ? null : parseInt(v, 10);
}
}
await API.sources.update(id, data);
document.getElementById('modal').classList.remove('active');
+83 -37
View File
@@ -28,6 +28,9 @@ class VideoPlayer {
this.isUsingProxy = false;
this.currentUrl = null;
this.settingsLoaded = false;
this.switchInProgress = false;
this.queuedSwitch = null;
this.lastSwitchAt = 0;
// Settings - start with defaults, load from server async
this.settings = this.getDefaultSettings();
@@ -53,7 +56,7 @@ class VideoPlayer {
forceTranscode: false,
forceRemux: false,
autoTranscode: true,
streamFormat: 'm3u8',
streamFormat: 'ts',
epgRefreshInterval: '24'
};
}
@@ -71,7 +74,7 @@ class VideoPlayer {
console.warn('[Player] Failed to load settings from server, using defaults:', err.message);
// Fall back to localStorage for backwards compatibility
try {
const saved = localStorage.getItem('nodecast_tv_player_settings');
const saved = localStorage.getItem('kiratv_player_settings');
if (saved) {
this.settings = { ...this.getDefaultSettings(), ...JSON.parse(saved) };
console.log('[Player] Settings loaded from localStorage (fallback)');
@@ -93,7 +96,7 @@ class VideoPlayer {
console.error('[Player] Error saving settings to server:', err);
// Also save to localStorage as backup
try {
localStorage.setItem('nodecast_tv_player_settings', JSON.stringify(this.settings));
localStorage.setItem('kiratv_player_settings', JSON.stringify(this.settings));
} catch (localErr) {
console.error('[Player] Error saving to localStorage:', localErr);
}
@@ -813,37 +816,58 @@ class VideoPlayer {
* Start a HLS transcode session
*/
async startTranscodeSession(url, options = {}) {
try {
console.log('[Player] Starting HLS transcode session...', options);
const res = await fetch('/api/transcode/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url, ...options })
});
if (!res.ok) throw new Error('Failed to start session');
const session = await res.json();
this.currentSessionId = session.sessionId;
return session.playlistUrl;
} catch (err) {
console.error('[Player] Session start failed:', err);
// Fallback to direct transcode if session fails
return `/api/transcode?url=${encodeURIComponent(url)}`;
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 {
const res = await fetch('/api/transcode/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url, ...options })
});
if (!res.ok) {
throw new Error(`Failed to start session (${res.status})`);
}
const session = await res.json();
this.currentSessionId = session.sessionId;
return session.playlistUrl;
} catch (err) {
lastError = err;
console.warn(`[Player] Session start failed (attempt ${attempt}/3):`, err?.message || err);
if (attempt < 3) {
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
*/
async stopTranscodeSession() {
if (this.currentSessionId) {
console.log('[Player] Stopping transcode session:', this.currentSessionId);
try {
// Fire and forget cleanup
fetch(`/api/transcode/${this.currentSessionId}`, { method: 'DELETE' });
} catch (err) {
if (!this.currentSessionId) return;
const sessionId = this.currentSessionId;
this.currentSessionId = null;
console.log('[Player] Stopping transcode session:', sessionId);
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 1500);
await fetch(`/api/transcode/${sessionId}`, {
method: 'DELETE',
signal: controller.signal
});
clearTimeout(timer);
} catch (err) {
if (err?.name !== 'AbortError') {
console.error('Failed to stop session:', err);
}
this.currentSessionId = null;
}
}
@@ -851,14 +875,26 @@ class VideoPlayer {
* Play a channel
*/
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;
try {
// Stop any WatchPage playback (movies/series) before starting Live TV
window.app?.pages?.watch?.stop?.();
// Stop current playback
this.stop();
// Stop current playback (sends DELETE to worker → FFmpeg dies → Dispatcharr frees the slot)
await 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');
// Hide "select a channel" overlay
@@ -932,15 +968,18 @@ class VideoPlayer {
window.dispatchEvent(new CustomEvent('channelChanged', { detail: channel }));
return;
} else if (info.needsRemux) {
// 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)}`;
this.currentUrl = remuxUrl;
this.video.src = remuxUrl;
this.video.play().catch(e => {
if (e.name !== 'AbortError') console.log('[Player] Autoplay prevented:', e);
// Algunos streams TS fallan en remux directo (ffmpeg mp4 mux errors).
// En ese caso es mas estable usar sesion HLS con video copy.
console.log('[Player] Auto: Using HLS transcode session (copy for remux-needed stream)');
this.updateTranscodeStatus('transcoding', 'Transcoding (Auto)');
const playlistUrl = await this.startTranscodeSession(streamUrl, {
videoMode: 'copy',
videoCodec: info.video,
audioCodec: info.audio,
audioChannels: info.audioChannels
});
this.currentUrl = playlistUrl;
this.playHls(playlistUrl);
this.updateNowPlaying(channel);
this.showNowPlayingOverlay();
this.fetchEpgData(channel);
@@ -1183,6 +1222,13 @@ class VideoPlayer {
} catch (err) {
console.error('Error playing channel:', err);
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);
}
}
}
@@ -1387,9 +1433,9 @@ class VideoPlayer {
/**
* Stop playback
*/
stop() {
async stop() {
// Stop any running transcode session first
this.stopTranscodeSession();
await this.stopTranscodeSession();
if (this.hls) {
this.hls.destroy();
+325
View File
@@ -0,0 +1,325 @@
/**
* KiraTV — helpers para escritorio (Electron o shell WebView2 + panel Win32).
*/
(function () {
function showToast(msg, type, durationMs) {
let container = document.getElementById('kiratv-desktop-toast');
if (!container) {
container = document.createElement('div');
container.id = 'kiratv-desktop-toast';
Object.assign(container.style, {
position: 'fixed',
bottom: '24px',
left: '50%',
transform: 'translateX(-50%)',
zIndex: '99999',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '8px',
pointerEvents: 'none'
});
document.body.appendChild(container);
}
const toast = document.createElement('div');
Object.assign(toast.style, {
background: type === 'error' ? '#c0392b' : '#1a7f4a',
color: '#fff',
padding: '10px 20px',
borderRadius: '8px',
fontSize: '14px',
fontFamily: 'sans-serif',
boxShadow: '0 4px 16px rgba(0,0,0,.5)',
opacity: '1',
transition: 'opacity 0.4s'
});
toast.textContent = msg;
container.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
setTimeout(() => toast.remove(), 450);
}, durationMs || 3500);
}
function unwrapDesktopWrapperUrl(urlLike) {
try {
const u = new URL(String(urlLike), window.location.origin);
const wrappedPath = u.pathname;
if (
wrappedPath === '/api/transcode' ||
wrappedPath === '/api/remux' ||
wrappedPath === '/api/proxy/stream'
) {
const original = u.searchParams.get('url');
if (original) return original;
}
} catch (_) {
/* */
}
return urlLike;
}
function absolutePlaybackUrl(streamUrl) {
if (!streamUrl) return '';
const raw = String(streamUrl).trim();
const s = String(unwrapDesktopWrapperUrl(raw)).trim();
if (/^https?:\/\//i.test(s)) return s;
return window.location.origin + (s.startsWith('/') ? s : '/' + s);
}
async function resolveNativePlaybackUrl(streamUrl) {
const abs = absolutePlaybackUrl(streamUrl);
try {
const u = new URL(abs);
if (/\/api\/proxy\/xtream\/[^/]+\/stream\/[^/]+/i.test(u.pathname)) {
const r = await fetch(abs, { credentials: 'include' });
if (r.ok) {
const j = await r.json();
if (j && typeof j.url === 'string' && j.url.trim()) {
return j.url.trim();
}
}
}
} catch (_) {
/* usar abs */
}
return abs;
}
function authBearerPayload() {
try {
const t = localStorage.getItem('authToken');
return t && t.length > 0 ? { authorizationBearer: t } : {};
} catch (_) {
return {};
}
}
function authBearerPayloadFor(url) {
try {
const u = new URL(url, window.location.origin);
if (u.origin !== window.location.origin) return {};
} catch (_) {
return {};
}
return authBearerPayload();
}
function elementToBounds(el) {
if (!el) return null;
const r = el.getBoundingClientRect();
return {
left: Math.round(r.left),
top: Math.round(r.top),
width: Math.max(1, Math.round(r.width)),
height: Math.max(1, Math.round(r.height))
};
}
/**
* Rect para Electron (--wid sobre Chromium). En WebView2+panel Win32 no se usa.
*/
function getEmbedBounds() {
if (!window.kiratvDesktop?.supportsEmbeddedMpv) return null;
if (window.kiratvDesktop.embedUsesNativePanel) return null;
const watchPage = document.getElementById('page-watch');
if (watchPage && watchPage.classList.contains('active')) {
const videoEl = document.getElementById('watch-video');
if (videoEl) return elementToBounds(videoEl);
const section = document.querySelector('.watch-video-section');
if (section) return elementToBounds(section);
}
const livePage = document.getElementById('page-live');
if (livePage && livePage.classList.contains('active')) {
const ps = document.querySelector('.player-section');
if (ps) return elementToBounds(ps);
}
const ps = document.querySelector('.player-section');
if (ps && ps.offsetParent !== null) return elementToBounds(ps);
const wv = document.getElementById('watch-video');
if (wv && wv.offsetParent !== null) return elementToBounds(wv);
return null;
}
function embedUsesNativePanel() {
return !!(window.kiratvDesktop && window.kiratvDesktop.embedUsesNativePanel);
}
function setEmbeddedUiClass(active) {
const native = embedUsesNativePanel();
document.body?.classList.toggle('kiratv-win-embedded', !!(active && !native));
document.body?.classList.toggle('kiratv-shell-wv2-playing', !!(active && native));
}
/**
* Modo cromo ventana: WebView2 oculta panel vídeo en rutas sin reproductor nativo.
*/
function shellChromeModeFromHash() {
const raw = location.hash || '#home';
const h = raw.replace(/^#\//, '#').replace(/^#/, '').split(/[/?&]/)[0];
if (h === 'live') return 'liveSplit';
if (h === 'watch') return 'vodSplit';
return 'fullWeb';
}
function syncShellChrome() {
const d = window.kiratvDesktop;
if (typeof d?.setShellChromeMode !== 'function') return;
const mode = shellChromeModeFromHash();
d.setShellChromeMode({ mode }).catch(() => {});
if (embedUsesNativePanel() && mode === 'fullWeb') {
d.stopExternalMpv?.().catch(() => {});
if (typeof window.kiratvDesktopClearEmbeddedChrome === 'function') {
window.kiratvDesktopClearEmbeddedChrome();
}
}
}
function initEmbedBoundsSync() {
if (!window.kiratvDesktop?.supportsEmbeddedMpv) return;
if (embedUsesNativePanel()) return;
if (typeof window.kiratvDesktop?.setVideoHostBounds !== 'function') return;
let t = null;
function flushBounds() {
clearTimeout(t);
t = setTimeout(() => {
const b = getEmbedBounds();
if (!b) return;
window.kiratvDesktop.setVideoHostBounds(b).catch(() => {});
}, 72);
}
window.addEventListener('resize', flushBounds);
if (typeof ResizeObserver !== 'undefined') {
const ro = new ResizeObserver(flushBounds);
document.querySelectorAll('.player-section, .watch-video-section, #watch-video').forEach((el) => {
try {
ro.observe(el);
} catch (_) {
/* */
}
});
}
window.addEventListener('hashchange', flushBounds);
flushBounds();
}
function useEmbeddedPlayback() {
const d = window.kiratvDesktop;
return !!(d?.supportsEmbeddedMpv && typeof d.playEmbeddedMpv === 'function');
}
async function playInMpv(absUrl, extraArgs) {
const d = window.kiratvDesktop;
if (!d?.playExternalMpv) {
showToast('Error: sin puente escritorio', 'error');
return { ok: false, error: 'Sin puente escritorio' };
}
const nativeUrl = await resolveNativePlaybackUrl(absUrl);
if (useEmbeddedPlayback()) {
const native = embedUsesNativePanel();
let bounds = null;
if (!native) {
bounds = getEmbedBounds();
if (!bounds || bounds.width < 2 || bounds.height < 2) {
showToast('No hay área de vídeo visible — cambia de página o redimensiona', 'error');
return { ok: false, error: 'bounds vídeo vacíos' };
}
showToast('▶ Reproduciendo en la misma ventana (mpv)', 'ok', 2800);
try {
await d.setVideoHostBounds(bounds);
} catch (_) {
/* */
}
} else {
showToast('▶ Reproduciendo en panel nativo (mpv)', 'ok', 2800);
}
setEmbeddedUiClass(true);
const payload = {
url: nativeUrl,
extraArgs: extraArgs || [],
...authBearerPayloadFor(nativeUrl)
};
if (!native && bounds) payload.bounds = bounds;
const result = await d.playEmbeddedMpv(payload);
if (!result?.ok) {
setEmbeddedUiClass(false);
const msg = result?.error || 'mpv no pudo arrancar';
console.error('[KiraTV Desktop]', msg);
showToast('⚠ ' + msg, 'error', 6000);
}
return result;
}
showToast('▶ Abriendo en reproductor nativo…', 'ok', 2500);
const result = await d.playExternalMpv({
url: nativeUrl,
extraArgs: extraArgs || [],
...authBearerPayloadFor(nativeUrl)
});
if (!result?.ok) {
const msg = result?.error || 'mpv no pudo arrancar';
console.error('[KiraTV Desktop]', msg);
showToast(
'⚠ ' + msg + (msg.includes('mpv') ? '' : ' — ¿mpv instalado?'),
'error',
6000
);
}
return result;
}
function whenReady(cb) {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', cb, { once: true });
} else {
cb();
}
}
whenReady(() => {
if (window.kiratvDesktop?.isDesktopApp) {
document.body.classList.add('kiratv-desktop-app');
}
if (window.kiratvDesktop?.supportsEmbeddedMpv) {
initEmbedBoundsSync();
}
window.addEventListener('hashchange', syncShellChrome);
setTimeout(syncShellChrome, 0);
});
window.kiratvDesktopClearEmbeddedChrome = function () {
setEmbeddedUiClass(false);
};
window.kiratvAbsolutePlaybackUrl = absolutePlaybackUrl;
window.kiratvEmbedBoundsFlush = () => getEmbedBounds();
window.kiratvPlayLiveInNativePlayer = function (absoluteUrl) {
return playInMpv(absoluteUrl);
};
window.kiratvPlayVodInNativePlayer = function (absoluteUrl, extraArgs) {
return playInMpv(absoluteUrl, extraArgs);
};
})();
+2 -2
View File
@@ -130,7 +130,7 @@ class SettingsPage {
if (forceTranscodeToggle) forceTranscodeToggle.checked = s.forceTranscode === true;
if (forceVideoTranscodeToggle) forceVideoTranscodeToggle.checked = s.forceVideoTranscode === true;
if (forceRemuxToggle) forceRemuxToggle.checked = s.forceRemux || false;
if (streamFormatSelect) streamFormatSelect.value = s.streamFormat || 'm3u8';
if (streamFormatSelect) streamFormatSelect.value = s.streamFormat || 'ts';
if (userAgentSelect) userAgentSelect.value = s.userAgentPreset || 'chrome';
if (userAgentCustomInput) userAgentCustomInput.value = s.userAgentCustom || '';
if (customUaContainer) {
@@ -575,7 +575,7 @@ class SettingsPage {
if (forceRemuxToggle) forceRemuxToggle.checked = s.forceRemux || false;
if (autoTranscodeToggle) autoTranscodeToggle.checked = s.autoTranscode || false;
if (epgRefreshSelect) epgRefreshSelect.value = s.epgRefreshInterval || '24';
if (streamFormatSelect) streamFormatSelect.value = s.streamFormat || 'm3u8';
if (streamFormatSelect) streamFormatSelect.value = s.streamFormat || 'ts';
// User-Agent settings
const userAgentSelect = document.getElementById('setting-user-agent');
+2 -2
View File
@@ -611,7 +611,7 @@ class WatchPage {
}
setVolumeFromStorage() {
const savedVolume = localStorage.getItem('nodecast-volume') || '80';
const savedVolume = localStorage.getItem('kiratv-volume') || '80';
this.video.volume = parseInt(savedVolume) / 100;
if (this.volumeSlider) this.volumeSlider.value = savedVolume;
}
@@ -677,7 +677,7 @@ class WatchPage {
if (this.video) {
this.video.volume = value / 100;
this.video.muted = false;
localStorage.setItem('nodecast-volume', value);
localStorage.setItem('kiratv-volume', value);
this.updateVolumeUI();
}
}
+5 -5
View File
@@ -1,10 +1,10 @@
<!DOCTYPE html>
<html lang="en">
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - NodeCast TV</title>
<title>Iniciar sesión — KiraTV</title>
<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">
@@ -171,13 +171,13 @@
<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" />
</svg>
<h1>nodecast-tv</h1>
<p id="login-subtitle">Sign in to continue</p>
<h1>KiraTV</h1>
<p id="login-subtitle">Inicia sesión para continuar</p>
</div>
<div class="error-message" id="error-message"></div>
<div class="setup-message" id="setup-message">
Welcome! Please create your admin account to get started.
Bienvenido. Crea tu cuenta de administrador para empezar.
</div>
<form id="login-form">
+2 -1
View File
@@ -67,7 +67,8 @@ function getDefaultSettings() {
forceVideoTranscode: false, // Force Video Transcode
forceRemux: false,
autoTranscode: true,
streamFormat: 'm3u8',
// Dispatcharr (y muchos Xtream) sirven MPEG-TS aunque la extensión sea .m3u8; Hls.js falla. Usar ts.
streamFormat: 'ts',
epgRefreshInterval: '24',
// User-Agent settings
userAgentPreset: 'chrome', // chrome | vlc | tivimate | custom
+94 -2
View File
@@ -27,7 +27,14 @@ app.use(session({
app.use(passport.initialize());
app.use(passport.session());
app.use(express.static(path.join(__dirname, '..', 'public')));
// Serve static files with no-cache for JS/CSS so browser always loads the latest version
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)
// Priority: 1. System FFmpeg (better Docker DNS support), 2. ffmpeg-static npm package
@@ -88,6 +95,86 @@ function findFFprobe() {
app.locals.ffmpegPath = findFFmpeg();
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
app.use('/api/auth', require('./routes/auth'));
app.use('/api/sources', require('./routes/sources'));
@@ -119,7 +206,12 @@ app.use((err, req, res, next) => {
});
app.listen(PORT, async () => {
console.log(`NodeCast TV server running on http://localhost:${PORT}`);
console.log(`KiraTV 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
setTimeout(async () => {
+163
View File
@@ -0,0 +1,163 @@
# 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
+25
View File
@@ -0,0 +1,25 @@
/**
* 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");
};
+66 -24
View File
@@ -1,6 +1,14 @@
const express = require('express');
const router = express.Router();
const { getDb } = require('../db/sqlite');
const { sources } = require('../db');
const { requireAuth } = require('../auth');
const {
getAccessibleSourceIds,
assertCanUseSourceById
} = require('../sourceAccess');
router.use(requireAuth);
// Helper to map API item types to DB types and tables
function mapItemType(apiType) {
@@ -21,27 +29,44 @@ router.get('/hidden', async (req, res) => {
const { sourceId } = req.query;
const db = getDb();
let hidden = [];
let allowedIds = null;
if (req.user.role !== 'admin') {
allowedIds = await getAccessibleSourceIds(req, sources);
if (allowedIds.length === 0) {
return res.json([]);
}
}
const resultFormat = (row, itemType) => ({
source_id: row.source_id,
item_type: itemType,
item_id: itemType.includes('category') || itemType === 'group' ? row.category_id : row.item_id
});
// Query Categories
let catQuery = `SELECT source_id, category_id, type FROM categories WHERE is_hidden = 1`;
let itemQuery = `SELECT source_id, item_id, type FROM playlist_items WHERE is_hidden = 1`;
let catParams = [];
let itemParams = [];
const params = [];
if (sourceId) {
if (!(await assertCanUseSourceById(req, res, sources, sourceId))) return;
const sid = parseInt(sourceId, 10);
catQuery += ` AND source_id = ?`;
itemQuery += ` AND source_id = ?`;
const sid = parseInt(sourceId);
params.push(sid);
catParams.push(sid);
itemParams.push(sid);
} else if (allowedIds) {
const ph = allowedIds.map(() => '?').join(',');
catQuery += ` AND source_id IN (${ph})`;
itemQuery += ` AND source_id IN (${ph})`;
catParams = [...allowedIds];
itemParams = [...allowedIds];
}
const hiddenCats = db.prepare(catQuery).all(...params);
const hiddenItems = db.prepare(itemQuery).all(...params);
const hiddenCats = db.prepare(catQuery).all(...catParams);
const hiddenItems = db.prepare(itemQuery).all(...itemParams);
const hidden = [];
hiddenCats.forEach(row => {
let apiType;
@@ -75,6 +100,7 @@ router.post('/hide', async (req, res) => {
const mapping = mapItemType(itemType);
if (!mapping) return res.status(400).json({ error: 'Invalid item type' });
if (!(await assertCanUseSourceById(req, res, sources, sourceId))) return;
const db = getDb();
const idCol = mapping.table === 'categories' ? 'category_id' : 'item_id';
@@ -101,6 +127,7 @@ router.post('/show', async (req, res) => {
const mapping = mapItemType(itemType);
if (!mapping) return res.status(400).json({ error: 'Invalid item type' });
if (!(await assertCanUseSourceById(req, res, sources, sourceId))) return;
const db = getDb();
const idCol = mapping.table === 'categories' ? 'category_id' : 'item_id';
@@ -127,6 +154,8 @@ router.get('/hidden/check', async (req, res) => {
const mapping = mapItemType(itemType);
if (!mapping) return res.json({ hidden: false });
if (!(await assertCanUseSourceById(req, res, sources, sourceId))) return;
const db = getDb();
const idCol = mapping.table === 'categories' ? 'category_id' : 'item_id';
@@ -148,13 +177,16 @@ router.post('/hide/bulk', async (req, res) => {
const { items } = req.body;
if (!Array.isArray(items)) return res.status(400).json({ error: 'items array required' });
const uniqueIds = [...new Set(items.map(i => i.sourceId))];
for (const sid of uniqueIds) {
if (!(await assertCanUseSourceById(req, res, sources, sid))) return;
}
const db = getDb();
// Prepare statements once
const hideCat = db.prepare('UPDATE categories SET is_hidden = 1 WHERE source_id = ? AND type = ? AND category_id = ?');
const hideItem = db.prepare('UPDATE playlist_items SET is_hidden = 1 WHERE source_id = ? AND type = ? AND item_id = ?');
// Cascading statements (hide all children of a category)
const hideCatChildren = db.prepare('UPDATE playlist_items SET is_hidden = 1 WHERE source_id = ? AND type = ? AND category_id = ?');
const runBulk = db.transaction((list) => {
@@ -162,12 +194,9 @@ router.post('/hide/bulk', async (req, res) => {
const mapping = mapItemType(item.itemType);
if (mapping) {
if (mapping.table === 'categories') {
// Hide the category
hideCat.run(item.sourceId, mapping.type, item.itemId);
// Cascade to children
hideCatChildren.run(item.sourceId, mapping.type, item.itemId);
} else {
// Hide individual item
hideItem.run(item.sourceId, mapping.type, item.itemId);
}
}
@@ -191,13 +220,16 @@ router.post('/show/bulk', async (req, res) => {
const { items } = req.body;
if (!Array.isArray(items)) return res.status(400).json({ error: 'items array required' });
const uniqueIds = [...new Set(items.map(i => i.sourceId))];
for (const sid of uniqueIds) {
if (!(await assertCanUseSourceById(req, res, sources, sid))) return;
}
const db = getDb();
// Prepare statements once
const showCat = db.prepare('UPDATE categories SET is_hidden = 0 WHERE source_id = ? AND type = ? AND category_id = ?');
const showItem = db.prepare('UPDATE playlist_items SET is_hidden = 0 WHERE source_id = ? AND type = ? AND item_id = ?');
// Cascading statements (show all children of a category)
const showCatChildren = db.prepare('UPDATE playlist_items SET is_hidden = 0 WHERE source_id = ? AND type = ? AND category_id = ?');
const runBulk = db.transaction((list) => {
@@ -205,12 +237,9 @@ router.post('/show/bulk', async (req, res) => {
const mapping = mapItemType(item.itemType);
if (mapping) {
if (mapping.table === 'categories') {
// Show the category
showCat.run(item.sourceId, mapping.type, item.itemId);
// Cascade to children
showCatChildren.run(item.sourceId, mapping.type, item.itemId);
} else {
// Show individual item
showItem.run(item.sourceId, mapping.type, item.itemId);
}
}
@@ -234,14 +263,15 @@ router.post('/show/all', async (req, res) => {
const { sourceId, contentType } = req.body;
if (!sourceId) return res.status(400).json({ error: 'sourceId required' });
if (!(await assertCanUseSourceById(req, res, sources, sourceId))) return;
const db = getDb();
let catCount = 0;
let itemCount = 0;
// Determine which types to update based on contentType
const types = contentType === 'movies' ? ['movie']
: contentType === 'series' ? ['series']
: ['live']; // default to channels
: ['live'];
for (const type of types) {
const catResult = db.prepare(`UPDATE categories SET is_hidden = 0 WHERE source_id = ? AND type = ?`).run(sourceId, type);
@@ -264,14 +294,15 @@ router.post('/hide/all', async (req, res) => {
const { sourceId, contentType } = req.body;
if (!sourceId) return res.status(400).json({ error: 'sourceId required' });
if (!(await assertCanUseSourceById(req, res, sources, sourceId))) return;
const db = getDb();
let catCount = 0;
let itemCount = 0;
// Determine which types to update based on contentType
const types = contentType === 'movies' ? ['movie']
: contentType === 'series' ? ['series']
: ['live']; // default to channels
: ['live'];
for (const type of types) {
const catResult = db.prepare(`UPDATE categories SET is_hidden = 1 WHERE source_id = ? AND type = ?`).run(sourceId, type);
@@ -296,6 +327,18 @@ router.get('/recent', async (req, res) => {
return res.status(400).json({ error: 'Valid type (movie or series) is required' });
}
let accessibleFilter = '';
const params = [type];
if (req.user.role !== 'admin') {
const ids = await getAccessibleSourceIds(req, sources);
if (ids.length === 0) {
return res.json([]);
}
accessibleFilter = ` AND p.source_id IN (${ids.map(() => '?').join(',')})`;
params.push(...ids);
}
params.push(parseInt(limit, 10));
const db = getDb();
const recentItems = db.prepare(`
SELECT * FROM playlist_items p
@@ -308,11 +351,11 @@ router.get('/recent', async (req, res) => {
AND c.type = p.type
AND c.is_hidden = 1
)
${accessibleFilter}
ORDER BY p.added_at DESC
LIMIT ?
`).all(type, parseInt(limit));
`).all(...params);
// Parse JSON data for each item
const formatted = recentItems.map(item => ({
...item,
data: JSON.parse(item.data)
@@ -326,4 +369,3 @@ router.get('/recent', async (req, res) => {
});
module.exports = router;
+44 -21
View File
@@ -170,32 +170,55 @@ router.get('/', async (req, res) => {
console.log(`[Probe] Probing: ${url.substring(0, 80)}... ${ua ? `(UA: ${ua})` : ''}`);
try {
const probeResult = await probeStream(url, ffprobePath, ua);
const analysis = analyzeProbeResult(probeResult, url);
// 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;
// Cache result
probeCache.set(cacheKey, { result: analysis, timestamp: Date.now() });
for (let attempt = 1; attempt <= MAX_PROBE_RETRIES; attempt++) {
try {
const probeResult = await probeStream(url, ffprobePath, ua);
const analysis = analyzeProbeResult(probeResult, url);
console.log(`[Probe] Result: video=${analysis.video}, audio=${analysis.audio}, ` +
`container=${analysis.container}, compatible=${analysis.compatible}, ` +
`needsRemux=${analysis.needsRemux}, needsTranscode=${analysis.needsTranscode}`);
// Cache successful result
probeCache.set(cacheKey, { result: analysis, timestamp: Date.now() });
res.json(analysis);
} catch (err) {
console.error('[Probe] Failed:', err.message);
console.log(`[Probe] Result (attempt ${attempt}): video=${analysis.video}, audio=${analysis.audio}, ` +
`container=${analysis.container}, compatible=${analysis.compatible}, ` +
`needsRemux=${analysis.needsRemux}, needsTranscode=${analysis.needsTranscode}`);
// On error, assume transcode needed to be safe
res.json({
video: 'unknown',
audio: 'unknown',
container: 'unknown',
compatible: false,
needsRemux: false,
needsTranscode: true,
error: err.message
});
return res.json(analysis);
} catch (err) {
lastErr = err;
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);
// 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({
video: 'unknown',
audio: 'unknown',
container: 'unknown',
compatible: false,
needsRemux: false,
needsTranscode: true,
error: lastErr.message
});
});
module.exports = router;
+45 -18
View File
@@ -12,6 +12,35 @@ const https = require('https');
const { spawn } = require('child_process');
const ffmpegPath = require('ffmpeg-static');
const { Readable } = require('stream');
const auth = require('../auth');
const { canUserAccessSource } = require('../sourceAccess');
// Rutas que el navegador pide sin Bearer (HLS, <img>, etc.) deben quedar fuera de JWT
router.use((req, res, next) => {
const p = req.path || (req.url || '').split('?')[0];
if (p === '/stream' || p === '/image') return next();
return auth.requireAuth(req, res, next);
});
router.param('sourceId', async (req, res, next, id) => {
try {
const sid = parseInt(id, 10);
if (Number.isNaN(sid)) {
return res.status(400).json({ error: 'Invalid source id' });
}
const source = await sources.getById(sid);
if (!source) {
return res.status(404).json({ error: 'Source not found' });
}
if (!canUserAccessSource(req.user, source)) {
return res.status(403).json({ error: 'No access to this source' });
}
req.grantedSource = source;
next();
} catch (err) {
next(err);
}
});
// Default cache max age in hours
const DEFAULT_MAX_AGE_HOURS = 24;
@@ -83,8 +112,8 @@ function getStreamsFromDb(sourceId, type, categoryId = null, includeHidden = fal
// Login / Authenticate
router.get('/xtream/:sourceId', async (req, res) => {
try {
const source = await sources.getById(req.params.sourceId);
if (!source || source.type !== 'xtream') return res.status(404).send('Source not found');
const source = req.grantedSource;
if (source.type !== 'xtream') return res.status(404).send('Source not found');
// Proxy auth check to upstream to ensure credentials are still valid
@@ -185,8 +214,7 @@ router.get('/xtream/:sourceId/series', async (req, res) => {
// Proxy series info request
router.get('/xtream/:sourceId/series_info', async (req, res) => {
try {
const source = await sources.getById(req.params.sourceId);
if (!source) return res.status(404).send('Source not found');
const source = req.grantedSource;
const seriesId = req.query.series_id;
if (!seriesId) return res.status(400).send('series_id required');
@@ -207,8 +235,7 @@ router.get('/xtream/:sourceId/series_info', async (req, res) => {
// VOD Info
router.get('/xtream/:sourceId/vod_info', async (req, res) => {
try {
const source = await sources.getById(req.params.sourceId);
if (!source) return res.status(404).send('Source not found');
const source = req.grantedSource;
const vodId = req.query.vod_id;
if (!vodId) return res.status(400).send('vod_id required');
@@ -230,14 +257,14 @@ router.get('/xtream/:sourceId/vod_info', async (req, res) => {
// Returns the direct stream URL for a given stream ID
router.get('/xtream/:sourceId/stream/:streamId/:type', async (req, res) => {
try {
const source = await sources.getById(req.params.sourceId);
if (!source || source.type !== 'xtream') {
const source = req.grantedSource;
if (source.type !== 'xtream') {
return res.status(404).json({ error: 'Xtream source not found' });
}
const streamId = req.params.streamId;
const type = req.params.type || 'live';
const container = req.query.container || 'm3u8';
const container = req.query.container || 'ts';
// Construct the Xtream stream URL
// Format: http://server:port/live/username/password/streamId.container (for live)
@@ -393,8 +420,8 @@ router.delete('/cache/:sourceId', (req, res) => {
router.get('/xtream/:sourceId/:action', async (req, res) => {
try {
const sourceId = req.params.sourceId;
const source = await sources.getById(sourceId);
if (!source || source.type !== 'xtream') {
const source = req.grantedSource;
if (source.type !== 'xtream') {
return res.status(404).json({ error: 'Xtream source not found' });
}
@@ -478,14 +505,14 @@ router.get('/xtream/:sourceId/:action', async (req, res) => {
*/
router.get('/xtream/:sourceId/stream/:streamId/:type?', async (req, res) => {
try {
const source = await sources.getById(req.params.sourceId);
if (!source || source.type !== 'xtream') {
const source = req.grantedSource;
if (source.type !== 'xtream') {
return res.status(404).json({ error: 'Xtream source not found' });
}
const api = xtreamApi.createFromSource(source);
const { streamId, type = 'live' } = req.params;
const { container = 'm3u8' } = req.query;
const { container = 'ts' } = req.query;
const url = api.buildStreamUrl(streamId, type, container);
res.json({ url });
@@ -505,8 +532,8 @@ router.get('/xtream/:sourceId/stream/:streamId/:type?', async (req, res) => {
router.get('/epg/:sourceId', async (req, res) => {
try {
const sourceId = req.params.sourceId;
const source = await sources.getById(sourceId);
if (!source || (source.type !== 'epg' && source.type !== 'xtream')) {
const source = req.grantedSource;
if (source.type !== 'epg' && source.type !== 'xtream') {
return res.status(404).json({ error: 'Valid EPG source not found' });
}
@@ -567,8 +594,8 @@ router.delete('/epg/:sourceId/cache', (req, res) => {
*/
router.post('/epg/:sourceId/channels', async (req, res) => {
try {
const source = await sources.getById(req.params.sourceId);
if (!source || source.type !== 'epg') {
const source = req.grantedSource;
if (source.type !== 'epg') {
return res.status(404).json({ error: 'EPG source not found' });
}
+137 -97
View File
@@ -5,29 +5,84 @@ const { getDb } = require('../db/sqlite');
const xtreamApi = require('../services/xtreamApi');
const syncService = require('../services/syncService');
const m3uParser = require('../services/m3uParser');
const { requireAuth, requireAdmin } = require('../auth');
const { canUserAccessSource, getAccessibleSourceIds } = require('../sourceAccess');
// Get all sources
router.use(requireAuth);
function sanitizeSourceList(list) {
return list.map((s) => ({
...s,
password: s.password ? '••••••••' : null
}));
}
function parseOwnerId(body) {
if (body.ownerId === undefined || body.ownerId === '') return null;
const n = parseInt(body.ownerId, 10);
return Number.isNaN(n) ? null : n;
}
// Estimate by URL (must stay before /:id routes)
const M3U_LARGE_THRESHOLD = 50000;
router.post('/estimate', async (req, res) => {
try {
const { url, type } = req.body;
if (!url) {
return res.status(400).json({ error: 'URL is required' });
}
if (type !== 'm3u') {
return res.json({ count: 0, needsWarning: false, threshold: M3U_LARGE_THRESHOLD });
}
const count = await m3uParser.countEntries(url);
res.json({
count,
needsWarning: count > M3U_LARGE_THRESHOLD,
threshold: M3U_LARGE_THRESHOLD
});
} catch (err) {
console.error('Error estimating M3U size:', err);
res.status(500).json({ error: 'Failed to estimate playlist size', message: err.message });
}
});
// Global sync — admin only
router.post('/sync-all', requireAdmin, async (req, res) => {
try {
syncService.syncAll().catch(console.error);
res.json({ success: true, message: 'Global sync started' });
} catch (err) {
console.error('Error starting global sync:', err);
res.status(500).json({ error: 'Failed to start global sync' });
}
});
// Get all sources (filtered by access)
router.get('/', async (req, res) => {
try {
const allSources = await sources.getAll();
// Don't expose passwords in list view
const sanitized = allSources.map(s => ({
...s,
password: s.password ? '••••••••' : null
}));
res.json(sanitized);
const filtered = allSources.filter((s) => canUserAccessSource(req.user, s));
res.json(sanitizeSourceList(filtered));
} catch (err) {
console.error('Error getting sources:', err);
res.status(500).json({ error: 'Failed to get sources' });
}
});
// Get sync status for all sources
// Get sync status (filtered for non-admins)
router.get('/status', async (req, res) => {
try {
const { getDb } = require('../db/sqlite');
const db = getDb();
const statuses = db.prepare('SELECT * FROM sync_status').all();
let statuses = db.prepare('SELECT * FROM sync_status').all();
if (req.user.role !== 'admin') {
const allowed = new Set(await getAccessibleSourceIds(req, sources));
statuses = statuses.filter((row) => allowed.has(row.source_id));
}
res.json(statuses);
} catch (err) {
console.error('Error getting sync status:', err);
@@ -39,13 +94,42 @@ router.get('/status', async (req, res) => {
router.get('/type/:type', async (req, res) => {
try {
const typeSources = await sources.getByType(req.params.type);
res.json(typeSources);
const filtered = typeSources.filter((s) => canUserAccessSource(req.user, s));
res.json(sanitizeSourceList(filtered));
} catch (err) {
console.error('Error getting sources by type:', err);
res.status(500).json({ error: 'Failed to get sources' });
}
});
// Estimate by source ID (before bare GET /:id — same base path length)
router.get('/:id/estimate', async (req, res) => {
try {
const source = await sources.getById(req.params.id);
if (!source) {
return res.status(404).json({ error: 'Source not found' });
}
if (!canUserAccessSource(req.user, source)) {
return res.status(403).json({ error: 'No access to this source' });
}
if (source.type !== 'm3u') {
return res.json({ count: 0, needsWarning: false, threshold: M3U_LARGE_THRESHOLD });
}
const count = await m3uParser.countEntries(source.url);
res.json({
count,
needsWarning: count > M3U_LARGE_THRESHOLD,
threshold: M3U_LARGE_THRESHOLD
});
} catch (err) {
console.error('Error estimating M3U size:', err);
res.status(500).json({ error: 'Failed to estimate playlist size', message: err.message });
}
});
// Get single source
router.get('/:id', async (req, res) => {
try {
@@ -53,6 +137,9 @@ router.get('/:id', async (req, res) => {
if (!source) {
return res.status(404).json({ error: 'Source not found' });
}
if (!canUserAccessSource(req.user, source)) {
return res.status(403).json({ error: 'No access to this source' });
}
res.json(source);
} catch (err) {
console.error('Error getting source:', err);
@@ -73,8 +160,14 @@ router.post('/', async (req, res) => {
return res.status(400).json({ error: 'Invalid source type' });
}
const source = await sources.create({ type, name, url, username, password });
// Trigger Sync
let ownerId = null;
if (req.user.role === 'admin') {
ownerId = parseOwnerId(req.body);
} else {
ownerId = req.user.id;
}
const source = await sources.create({ type, name, url, username, password, ownerId });
syncService.syncSource(source.id).catch(console.error);
res.status(201).json(source);
} catch (err) {
@@ -90,16 +183,23 @@ router.put('/:id', async (req, res) => {
if (!existing) {
return res.status(404).json({ error: 'Source not found' });
}
if (!canUserAccessSource(req.user, existing)) {
return res.status(403).json({ error: 'No access to this source' });
}
const { name, url, username, password } = req.body;
const updated = await sources.update(req.params.id, {
const updates = {
name: name || existing.name,
url: url || existing.url,
username: username !== undefined ? username : existing.username,
password: password !== undefined ? password : existing.password
});
// Trigger Sync (if critical fields changed? safely just trigger it)
syncService.syncSource(parseInt(req.params.id)).catch(console.error);
};
if (req.user.role === 'admin' && req.body.ownerId !== undefined) {
updates.ownerId = parseOwnerId(req.body);
}
const updated = await sources.update(req.params.id, updates);
syncService.syncSource(parseInt(req.params.id, 10)).catch(console.error);
res.json(updated);
} catch (err) {
console.error('Error updating source:', err);
@@ -110,13 +210,15 @@ router.put('/:id', async (req, res) => {
// Delete source
router.delete('/:id', async (req, res) => {
try {
const sourceId = parseInt(req.params.id);
const sourceId = parseInt(req.params.id, 10);
const existing = await sources.getById(sourceId);
if (!existing) {
return res.status(404).json({ error: 'Source not found' });
}
if (!canUserAccessSource(req.user, existing)) {
return res.status(403).json({ error: 'No access to this source' });
}
// Cascade delete: Clean up SQLite data for this source
const db = getDb();
const deleteCategories = db.prepare('DELETE FROM categories WHERE source_id = ?');
const deleteItems = db.prepare('DELETE FROM playlist_items WHERE source_id = ?');
@@ -130,7 +232,6 @@ router.delete('/:id', async (req, res) => {
console.log(`[Source] Cascade delete for source ${sourceId}: ${catResult.changes} categories, ${itemResult.changes} items, ${epgResult.changes} EPG programs`);
// Delete source config and related hidden items (favorites handled by db.js)
await sources.delete(sourceId);
res.json({ success: true });
@@ -143,14 +244,21 @@ router.delete('/:id', async (req, res) => {
// Toggle source enabled/disabled
router.post('/:id/toggle', async (req, res) => {
try {
const existing = await sources.getById(req.params.id);
if (!existing) {
return res.status(404).json({ error: 'Source not found' });
}
if (!canUserAccessSource(req.user, existing)) {
return res.status(403).json({ error: 'No access to this source' });
}
const updated = await sources.toggleEnabled(req.params.id);
if (!updated) {
return res.status(404).json({ error: 'Source not found' });
}
// If enabled, trigger sync
if (updated.enabled) {
syncService.syncSource(parseInt(req.params.id)).catch(console.error);
syncService.syncSource(parseInt(req.params.id, 10)).catch(console.error);
}
res.json(updated);
@@ -163,11 +271,13 @@ router.post('/:id/toggle', async (req, res) => {
// Manual Sync
router.post('/:id/sync', async (req, res) => {
try {
const id = parseInt(req.params.id);
const id = parseInt(req.params.id, 10);
const source = await sources.getById(id);
if (!source) return res.status(404).json({ error: 'Source not found' });
if (!canUserAccessSource(req.user, source)) {
return res.status(403).json({ error: 'No access to this source' });
}
// Trigger sync (async)
syncService.syncSource(id).catch(console.error);
res.json({ success: true, message: 'Sync started' });
@@ -184,6 +294,9 @@ router.post('/:id/test', async (req, res) => {
if (!source) {
return res.status(404).json({ error: 'Source not found' });
}
if (!canUserAccessSource(req.user, source)) {
return res.status(403).json({ error: 'No access to this source' });
}
if (source.type === 'xtream') {
const result = await xtreamApi.authenticate(source.url, source.username, source.password);
@@ -205,77 +318,4 @@ router.post('/:id/test', async (req, res) => {
}
});
// Estimate M3U playlist size (for large playlist warning)
const M3U_LARGE_THRESHOLD = 50000;
// Estimate by URL (for new sources before creation)
router.post('/estimate', async (req, res) => {
try {
const { url, type } = req.body;
if (!url) {
return res.status(400).json({ error: 'URL is required' });
}
// Only M3U sources need estimation
if (type !== 'm3u') {
return res.json({ count: 0, needsWarning: false, threshold: M3U_LARGE_THRESHOLD });
}
console.log(`[Sources] Estimating M3U size for URL...`);
const count = await m3uParser.countEntries(url);
console.log(`[Sources] M3U estimate: ${count} entries`);
res.json({
count,
needsWarning: count > M3U_LARGE_THRESHOLD,
threshold: M3U_LARGE_THRESHOLD
});
} catch (err) {
console.error('Error estimating M3U size:', err);
res.status(500).json({ error: 'Failed to estimate playlist size', message: err.message });
}
});
// Estimate by source ID (for existing sources)
router.get('/:id/estimate', async (req, res) => {
try {
const source = await sources.getById(req.params.id);
if (!source) {
return res.status(404).json({ error: 'Source not found' });
}
// Only M3U sources need estimation
if (source.type !== 'm3u') {
return res.json({ count: 0, needsWarning: false, threshold: M3U_LARGE_THRESHOLD });
}
console.log(`[Sources] Estimating M3U size for ${source.name}...`);
const count = await m3uParser.countEntries(source.url);
console.log(`[Sources] M3U estimate: ${count} entries`);
res.json({
count,
needsWarning: count > M3U_LARGE_THRESHOLD,
threshold: M3U_LARGE_THRESHOLD
});
} catch (err) {
console.error('Error estimating M3U size:', err);
res.status(500).json({ error: 'Failed to estimate playlist size', message: err.message });
}
});
// Global Sync - sync all enabled sources
router.post('/sync-all', async (req, res) => {
try {
// Trigger global sync (async - don't wait for completion)
syncService.syncAll().catch(console.error);
res.json({ success: true, message: 'Global sync started' });
} catch (err) {
console.error('Error starting global sync:', err);
res.status(500).json({ error: 'Failed to start global sync' });
}
});
module.exports = router;
+134 -26
View File
@@ -1,10 +1,45 @@
const express = require('express');
const router = express.Router();
const { spawn } = require('child_process');
const { spawn, execFile } = require('child_process');
const path = require('path');
const fs = require('fs').promises;
const db = require('../db');
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
@@ -40,38 +75,78 @@ router.post('/session', async (req, res) => {
const userAgent = db.getUserAgent(settings);
try {
const session = await transcodeSession.createSession(url, {
ffmpegPath,
userAgent,
seekOffset: seekOffset || 0,
hwEncoder: settings.hwEncoder || 'software',
maxResolution: settings.maxResolution || '1080p',
quality: settings.quality || 'medium',
audioMixPreset: settings.audioMixPreset || 'auto', // Audio downmix preset
// Upscaling options
upscaleEnabled: settings.upscaleEnabled || false,
upscaleMethod: settings.upscaleMethod || 'hardware',
upscaleTarget: settings.upscaleTarget || '1080p',
videoMode: videoMode, // 'copy' or 'encode'
videoCodec: videoCodec, // 'h264', 'hevc', etc.
audioCodec: audioCodec, // 'aac', 'ac3', etc.
audioChannels: audioChannels // number of channels (2=stereo)
});
const ffprobePath = (ffmpegPath || 'ffmpeg').replace(/ffmpeg$/, 'ffprobe');
await session.start();
// Probe duration and start session in parallel (don't block on duration)
const [session, durationSec] = await Promise.all([
transcodeSession.createSession(url, {
ffmpegPath,
userAgent,
seekOffset: seekOffset || 0,
hwEncoder: settings.hwEncoder || 'software',
maxResolution: settings.maxResolution || '1080p',
quality: settings.quality || 'medium',
audioMixPreset: settings.audioMixPreset || 'auto',
upscaleEnabled: settings.upscaleEnabled || false,
upscaleMethod: settings.upscaleMethod || 'hardware',
upscaleTarget: settings.upscaleTarget || '1080p',
videoMode: videoMode,
videoCodec: videoCodec,
audioCodec: audioCodec,
audioChannels: audioChannels
}).then(async s => { await s.start(); return s; }),
probeDuration(url, ffprobePath, userAgent)
]);
// Wait for playlist to be ready (first segments generated)
const ready = await session.waitForPlaylist(15000);
// VOD pesado (4K HEVC/remux) puede tardar >15 s en generar el primer segmento
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) {
await transcodeSession.removeSession(session.id);
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({
sessionId: session.id,
playlistUrl: `/api/transcode/${session.id}/stream.m3u8`,
status: session.status
status: session.status,
durationSec: durationSec || 0
});
} catch (err) {
@@ -144,6 +219,30 @@ 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)
* GET /api/transcode/sessions
@@ -175,6 +274,9 @@ router.get('/', async (req, res) => {
console.log(`[Transcode] Using User-Agent: ${settings.userAgentPreset}`);
console.log(`[Transcode] Using binary: ${ffmpegPath}`);
const sizing = streamInput.probeSizingForUrl(url);
const referer = streamInput.deriveRefererFromUrl(url);
// FFmpeg arguments for transcoding
// Optimized for VOD content with incompatible audio (Dolby/AC3/EAC3)
// Also works for live streams with ad stitching (Pluto TV, etc.)
@@ -182,9 +284,13 @@ router.get('/', async (req, res) => {
'-hide_banner',
'-loglevel', 'warning',
'-user_agent', userAgent,
// Faster startup - reduced probe/analyze for quicker first bytes
'-probesize', '2000000', // 2MB (reduced from 5MB)
'-analyzeduration', '3000000', // 3 seconds (reduced from 10s)
];
if (referer) {
args.push('-referer', referer);
}
args.push(
'-probesize', sizing.probesize,
'-analyzeduration', sizing.analyzeduration,
// Error resilience: generate timestamps, discard corrupt packets
'-fflags', '+genpts+discardcorrupt+nobuffer',
// Ignore errors in stream and continue
@@ -197,7 +303,9 @@ router.get('/', async (req, res) => {
'-reconnect_delay_max', '3',
// Prevent Range/HEAD requests that some providers reject with 405
'-seekable', '0',
'-i', url,
'-i', url
);
args.push(
// Map only first video and audio stream (avoid subtitle streams causing issues)
'-map', '0:v:0',
'-map', '0:a:0?', // ? makes audio optional if not present
@@ -218,7 +326,7 @@ router.get('/', async (req, res) => {
'-movflags', 'frag_keyframe+empty_moov+default_base_moof+faststart',
'-flush_packets', '1', // Send data immediately
'-' // Output to stdout
];
);
console.log(`[Transcode] Full command: ${ffmpegPath} ${args.join(' ')}`);
+5 -3
View File
@@ -228,16 +228,18 @@ async function detect() {
detectAMF()
]);
// Determine recommended encoder (priority: NVENC > AMF > QSV > VAAPI > Software)
// Determine recommended encoder
// 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';
if (nvidia.available) {
recommended = 'nvenc';
} else if (amf.available) {
recommended = 'amf';
} else if (qsv.available) {
recommended = 'qsv';
} else if (vaapi.available) {
recommended = 'vaapi';
} else if (qsv.available) {
recommended = 'qsv';
}
hwCapabilities = {
@@ -0,0 +1,781 @@
/**
* 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
};
+33 -18
View File
@@ -18,6 +18,7 @@ const fs = require('fs').promises;
const crypto = require('crypto');
const EventEmitter = require('events');
const hwDetect = require('./hwDetect');
const streamInput = require('../utils/streamInput');
// Session storage
const sessions = new Map();
@@ -26,9 +27,9 @@ const sessions = new Map();
const CACHE_DIR = path.join(process.cwd(), 'transcode-cache');
// Session settings
const SESSION_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes idle timeout
const SESSION_TIMEOUT_MS = 3 * 60 * 1000; // 3 minutes idle timeout (kills orphaned sessions quickly)
const SEGMENT_DURATION = 4; // seconds per HLS segment
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // Check every 5 minutes
const CLEANUP_INTERVAL_MS = 60 * 1000; // Check every 60 seconds
/**
* Generate a unique session ID
@@ -181,11 +182,17 @@ class TranscodeSession extends EventEmitter {
console.log(`[TranscodeSession ${this.id}] Auto encoder resolved to: ${encoder}`);
}
const sizing = streamInput.probeSizingForUrl(this.url);
const referer = streamInput.deriveRefererFromUrl(this.url);
const args = [
'-hide_banner',
'-loglevel', 'warning',
'-user_agent', this.options.userAgent,
];
if (referer) {
args.push('-referer', referer);
}
// Add hardware acceleration input options based on encoder (only if encoding)
if (videoMode === 'encode') {
@@ -194,8 +201,8 @@ class TranscodeSession extends EventEmitter {
// Input options (common)
args.push(
'-probesize', '5000000',
'-analyzeduration', '5000000',
'-probesize', sizing.probesize,
'-analyzeduration', sizing.analyzeduration,
'-fflags', '+genpts+discardcorrupt',
'-err_detect', 'ignore_err',
'-reconnect', '1',
@@ -222,7 +229,10 @@ class TranscodeSession extends EventEmitter {
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') {
args.push('-bsf:v', 'h264_mp4toannexb');
// 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');
@@ -296,12 +306,12 @@ class TranscodeSession extends EventEmitter {
);
break;
case 'vaapi':
// VAAPI hardware decoding (Linux)
args.push(
'-hwaccel', 'vaapi',
'-hwaccel_device', '/dev/dri/renderD128',
'-hwaccel_output_format', 'vaapi'
);
// Solo registramos el dispositivo VAAPI globalmente.
// Se usa decodificación SOFTWARE para máxima compatibilidad con
// cualquier codec de entrada (HEVC, VP9, etc.) — el GPU Intel
// puede no tener soporte de decodificación para todos ellos.
// El upload a memoria VAAPI se hace en el filtro de vídeo.
args.push('-vaapi_device', '/dev/dri/renderD128');
break;
case 'qsv':
// Intel QuickSync hardware decoding
@@ -404,7 +414,9 @@ class TranscodeSession extends EventEmitter {
// 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`;
// Software decode → scale → nv12 → hwupload → VAAPI encode
// format=nv12 ANTES de hwupload es obligatorio para Intel iGPU en LXC
return `scale=-2:${height},format=nv12,hwupload`;
case 'qsv':
return `scale_qsv=w=-2:h=${height}:format=nv12`;
case 'amf':
@@ -466,14 +478,11 @@ class TranscodeSession extends EventEmitter {
// 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
// VAAPI encoder. No especificar -profile:v ni -pix_fmt (los gestiona el driver).
// -bf 3 puede causar problemas en algunos drivers Intel; se omite.
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
'-global_quality', String(qp)
);
}
@@ -551,6 +560,7 @@ class TranscodeSession extends EventEmitter {
/**
* 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();
@@ -558,6 +568,11 @@ class TranscodeSession extends EventEmitter {
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;
+42
View File
@@ -0,0 +1,42 @@
/**
* Who may use a source for playback and API access.
* ownerId null/undefined: legacy shared — any authenticated user may use it.
* ownerId N: only that user id (and admins) may use it.
*/
function canUserAccessSource(user, source) {
if (!user || !source) return false;
if (user.role === 'admin') return true;
const oid = source.ownerId;
if (oid == null || oid === undefined) return true;
return Number(oid) === Number(user.id);
}
async function getAccessibleSourceIds(req, sourcesApi) {
const all = await sourcesApi.getAll();
return all.filter((s) => canUserAccessSource(req.user, s)).map((s) => s.id);
}
/** Returns false after sending res if missing, forbidden, or invalid. */
async function assertCanUseSourceById(req, res, sourcesApi, rawSourceId) {
const sid = typeof rawSourceId === 'number' ? rawSourceId : parseInt(rawSourceId, 10);
if (Number.isNaN(sid)) {
res.status(400).json({ error: 'Invalid source id' });
return false;
}
const source = await sourcesApi.getById(sid);
if (!source) {
res.status(404).json({ error: 'Source not found' });
return false;
}
if (!canUserAccessSource(req.user, source)) {
res.status(403).json({ error: 'No access to this source' });
return false;
}
return true;
}
module.exports = {
canUserAccessSource,
getAccessibleSourceIds,
assertCanUseSourceById
};
+38
View File
@@ -0,0 +1,38 @@
'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
};