From 72e14dca577f76ebd166dac33ecd41da22cb8bb3 Mon Sep 17 00:00:00 2001 From: Technomancer702 Date: Thu, 5 Feb 2026 01:42:40 -0800 Subject: [PATCH] feat: enhance plugin system with async support and lifecycle hooks & reverted PR #79 due to Node.js v24 stream compatibility issues --- nodecast.patch | 869 +++++++++++++++++++++++++++++++++++ server/index.js | 76 ++- server/plugins/PLUGINS.md | 154 ++++++- server/plugins/hello.js | 25 +- server/services/epgParser.js | 44 +- test-plugin-system.js | 72 +++ 6 files changed, 1177 insertions(+), 63 deletions(-) create mode 100644 nodecast.patch create mode 100644 test-plugin-system.js diff --git a/nodecast.patch b/nodecast.patch new file mode 100644 index 0000000..e2f2553 --- /dev/null +++ b/nodecast.patch @@ -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 @@ + + nodecast-tv | IPTV Player + +- ++ + + + +@@ -1095,20 +1095,20 @@ + + + +- +- +- +- +- +- +- +- +- +- +- +- +- +- ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ + + + +- +\ No newline at end of file ++ +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}"> + ++ alt="" onerror="this.onerror=null;this.src='img/placeholder.png'"> +
+
${this.escapeHtml(channel.name)}
+
${this.escapeHtml(this.getProgramInfo(channel) || '')}
+@@ -623,7 +623,7 @@ class ChannelList { + data-render-id="${renderId}" + data-render-group="${renderGroup}"> + ++ alt="" onerror="this.onerror=null;this.src='img/placeholder.png'"> +
+
${this.escapeHtml(channel.name)}
+
${this.escapeHtml(this.getProgramInfo(channel) || '')}
+@@ -1052,7 +1052,7 @@ class ChannelList { + + div.innerHTML = ` + ++ alt="" onerror="this.onerror=null;this.src='img/placeholder.png'"> +
+
${this.escapeHtml(channel.name)}
+
${this.getProgramInfo(channel) || ''}
+@@ -1305,7 +1305,7 @@ class ChannelList { +
+
+ ++ onerror="this.onerror=null;this.src='img/placeholder.png'" /> +
+

Group: ${this.escapeHtml(channel.groupTitle || 'Uncategorized')}

+

Source: ${channel.sourceType}

+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} + + ++ alt="" onerror="this.onerror=null;this.src='img/placeholder.png'"> + ${name} +
+
+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 = '
Add channels to favorites from Live TV
'; +@@ -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 ` +
+ +
${name}
+
+@@ -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 = '
No recently added movies found
'; + 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 = '
No recently added series found
'; + 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 ` +
+
+- ${data.title || item.name} ++ ${data.title || item.name} +
+
+
+@@ -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 ` +
+
+- ${item.name} ++ ${item.name} +
+ +
+@@ -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 = ` +
+ ${movie.name} ++ onerror="this.onerror=null;this.src='img/placeholder.png'" loading="lazy"> +
+ ${Icons.play} +
+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 = ` +
+ ${series.name} ++ onerror="this.onerror=null;this.src='img/placeholder.png'" loading="lazy"> +
+ ${Icons.play} +
+@@ -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 => ` + + `).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 @@ + + + Login - NodeCast TV +- ++ + + +