Instancia transcoder t1: cambios locales

This commit is contained in:
Joaquin
2026-07-13 15:19:13 +00:00
parent 0e26a90dae
commit bb189a472e
27 changed files with 2161 additions and 496 deletions
+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();