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
+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
+4 -4
View File
@@ -1,12 +1,12 @@
{ {
"name": "nodecast-tv", "name": "kiratv",
"version": "2.1.4", "version": "2.1.4-kira1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "nodecast-tv", "name": "kiratv",
"version": "2.1.4", "version": "2.1.4-kira1",
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"dependencies": { "dependencies": {
"@ffprobe-installer/ffprobe": "^2.1.2", "@ffprobe-installer/ffprobe": "^2.1.2",
+3 -3
View File
@@ -1,7 +1,7 @@
{ {
"name": "nodecast-tv", "name": "kiratv",
"version": "2.1.4", "version": "2.1.4-kira1",
"description": "Modern IPTV Player", "description": "KiraTV — reproductor IPTV para el navegador (fork de NodeCast)",
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"main": "server/index.js", "main": "server/index.js",
"scripts": { "scripts": {
+108 -41
View File
@@ -1,5 +1,5 @@
/* ===================================================== /* =====================================================
* NodeCast TV - Dark Theme Design System * KiraTV — tema oscuro (azul marino + oro)
===================================================== */ ===================================================== */
/* CSS Variables */ /* CSS Variables */
@@ -7,32 +7,37 @@
/* iOS Safari toolbar compensation (set dynamically by JS) */ /* iOS Safari toolbar compensation (set dynamically by JS) */
--ios-ui-bottom: 0px; --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 */ /* Colors */
--color-bg-primary: #0a0a0f; --color-bg-primary: #000022;
--color-bg-secondary: #12121a; --color-bg-secondary: #050038;
--color-bg-tertiary: #1a1a25; --color-bg-tertiary: #0a0a4d;
--color-bg-hover: #22222f; --color-bg-hover: #12125c;
--color-bg-active: #2a2a3a; --color-bg-active: #1a1a72;
--color-accent: #6366f1; --color-accent-dim: var(--color-accent-muted);
--color-accent-hover: #818cf8;
--color-accent-dim: rgba(99, 102, 241, 0.2);
--color-success: #10b981; --color-success: #34d399;
--color-warning: #f59e0b; --color-warning: #fbbf24;
--color-error: #ef4444; --color-error: #f87171;
--color-text-primary: #f1f1f5; --color-text-primary: #f4f6fb;
--color-text-secondary: #a1a1aa; --color-text-secondary: #a8b0c4;
--color-text-muted: #71717a; --color-text-muted: #6b7389;
--color-border: #27272a; --color-border: #243047;
--color-border-light: #3f3f46; --color-border-light: #344062;
/* Glass effect */ /* Glass effect */
--glass-bg: rgba(18, 18, 26, 0.8); --glass-bg: rgba(0, 0, 102, 0.88);
--glass-border: rgba(255, 255, 255, 0.1); --glass-border: rgba(255, 255, 255, 0.08);
/* Spacing */ /* Spacing */
--space-xs: 4px; --space-xs: 4px;
@@ -52,7 +57,7 @@
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.3); --shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4); --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5); --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 */ /* Transitions */
--transition-fast: 150ms ease; --transition-fast: 150ms ease;
@@ -83,11 +88,14 @@
html, html,
body { body {
height: 100%; 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; font-size: 14px;
line-height: 1.5; line-height: 1.5;
color: var(--color-text-primary); 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; overflow: hidden;
/* Prevent pull-to-refresh and overscroll bounce on mobile */ /* Prevent pull-to-refresh and overscroll bounce on mobile */
overscroll-behavior: none; overscroll-behavior: none;
@@ -137,6 +145,9 @@ h6 {
h2 { h2 {
font-size: 1.5rem; font-size: 1.5rem;
font-family: 'Montserrat', 'DM Sans', sans-serif;
font-weight: 700;
letter-spacing: -0.02em;
} }
h3 { h3 {
@@ -168,8 +179,9 @@ h4 {
padding-left: max(var(--space-lg), var(--safe-area-inset-left)); padding-left: max(var(--space-lg), var(--safe-area-inset-left));
padding-right: max(var(--space-lg), var(--safe-area-inset-right)); padding-right: max(var(--space-lg), var(--safe-area-inset-right));
background: var(--glass-bg); background: var(--glass-bg);
backdrop-filter: blur(12px); backdrop-filter: blur(14px);
border-bottom: 1px solid var(--glass-border); border-bottom: 1px solid var(--glass-border);
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.06);
z-index: 100; z-index: 100;
} }
@@ -190,12 +202,24 @@ h4 {
fill: var(--color-accent); 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 { .brand-text {
font-family: 'Century Gothic', sans-serif; font-family: 'Montserrat', 'DM Sans', sans-serif;
font-size: 1.5rem; font-size: 1.45rem;
font-weight: 500; font-weight: 600;
letter-spacing: -0.03em; letter-spacing: -0.02em;
color: #fff; color: var(--color-text-primary);
/* Visual correction: shift text slightly down to align optically with icon center */ /* Visual correction: shift text slightly down to align optically with icon center */
padding-top: 2px; padding-top: 2px;
} }
@@ -207,8 +231,8 @@ h4 {
.version-badge { .version-badge {
font-size: 0.65rem; font-size: 0.65rem;
font-weight: 600; font-weight: 600;
background: var(--color-accent); background: linear-gradient(135deg, var(--color-accent) 0%, #c9a875 100%);
color: #fff; color: var(--color-on-accent);
padding: 2px 6px; padding: 2px 6px;
border-radius: 10px; border-radius: 10px;
margin-left: 8px; margin-left: 8px;
@@ -1492,13 +1516,15 @@ kbd {
} }
.btn-primary { .btn-primary {
background: var(--color-accent); background: linear-gradient(135deg, var(--color-accent) 0%, #c9a875 100%);
color: white; color: var(--color-on-accent);
font-weight: 600;
} }
.btn-primary:hover { .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); box-shadow: var(--shadow-glow);
color: var(--color-on-accent);
} }
.btn-secondary { .btn-secondary {
@@ -4415,9 +4441,9 @@ kbd {
} }
.user-badge-sso { .user-badge-sso {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); background: linear-gradient(135deg, var(--color-accent) 0%, #c9a875 100%);
color: #fff; color: var(--color-on-accent);
box-shadow: 0 2px 4px rgba(99, 102, 241, 0.3); box-shadow: 0 2px 4px rgba(232, 213, 181, 0.22);
} }
.user-badge-local { .user-badge-local {
@@ -4448,24 +4474,24 @@ kbd {
gap: 8px; gap: 8px;
padding: 6px 14px; padding: 6px 14px;
margin-right: var(--space-md); 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); border-radius: var(--radius-full);
color: white; color: var(--color-on-accent);
text-decoration: none; text-decoration: none;
font-size: 13px; font-size: 13px;
font-weight: 500; font-weight: 600;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
max-width: 200px; 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); transition: transform var(--transition-fast), box-shadow var(--transition-fast);
animation: now-playing-pulse 2s ease-in-out infinite; animation: now-playing-pulse 2s ease-in-out infinite;
} }
.now-playing-indicator:hover { .now-playing-indicator:hover {
transform: scale(1.05); 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 { .now-playing-indicator.hidden {
@@ -4820,3 +4846,44 @@ kbd {
max-width: 70px; 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"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<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"/> <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> </svg>

Before

Width:  |  Height:  |  Size: 764 B

After

Width:  |  Height:  |  Size: 553 B

+162 -166
View File
@@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="es">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
@@ -7,14 +7,14 @@
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
<title>nodecast-tv | IPTV Player</title> <title>KiraTV | Televisión en el navegador</title>
<meta name="description" content="Stream live TV, movies, and series with EPG support"> <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="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.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <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" <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">
rel="stylesheet">
<!-- HLS.js for video playback --> <!-- HLS.js for video playback -->
<script src="https://cdn.jsdelivr.net/npm/hls.js@1.5.7/dist/hls.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/hls.js@1.5.7/dist/hls.min.js"></script>
</head> </head>
@@ -24,15 +24,10 @@
<!-- Navigation --> <!-- Navigation -->
<nav class="navbar"> <nav class="navbar">
<div class="navbar-brand"> <div class="navbar-brand">
<span class="logo"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 187.63 210.6" fill="#6366f1" <img src="/img/kiratv-logo-hero.jpg" alt="KiraTV" class="navbar-logo-img" width="176" height="44" decoding="async">
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>
<span id="version-badge" class="version-badge"></span> <span id="version-badge" class="version-badge"></span>
</div> </div>
<button class="mobile-menu-toggle" id="mobile-menu-toggle" aria-label="Toggle menu"> <button class="mobile-menu-toggle" id="mobile-menu-toggle" aria-label="Abrir o cerrar menú">
<span></span> <span></span>
<span></span> <span></span>
<span></span> <span></span>
@@ -41,14 +36,14 @@
<!-- Now Playing indicator for movies/series --> <!-- Now Playing indicator for movies/series -->
<a href="#" class="now-playing-indicator hidden" id="now-playing-indicator" data-page="watch"> <a href="#" class="now-playing-indicator hidden" id="now-playing-indicator" data-page="watch">
<span class="now-playing-icon">▶</span> <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>
<a href="#" class="nav-link active" data-page="home"> <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" <span class="nav-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
class="icon"> class="icon">
<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z" /> <path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z" />
</svg></span> </svg></span>
<span>Home</span> <span>Inicio</span>
</a> </a>
<a href="#" class="nav-link" data-page="live"> <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" <span class="nav-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
@@ -56,14 +51,14 @@
<path <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" /> 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> </svg></span>
<span>Live TV</span> <span>TV en directo</span>
</a> </a>
<a href="#" class="nav-link" data-page="guide"> <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" <span class="nav-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
class="icon"> class="icon">
<path d="M3 13h2v-2H3v2zm0 4h2v-2H3v2zm0-8h2V7H3v2zm4 4h14v-2H7v2zm0 4h14v-2H7v2zM7 7v2h14V7H7z" /> <path d="M3 13h2v-2H3v2zm0 4h2v-2H3v2zm0-8h2V7H3v2zm4 4h14v-2H7v2zm0 4h14v-2H7v2zM7 7v2h14V7H7z" />
</svg></span> </svg></span>
<span>TV Guide</span> <span>Guía TV</span>
</a> </a>
<a href="#" class="nav-link" data-page="movies"> <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" <span class="nav-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
@@ -71,7 +66,7 @@
<path <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" /> 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> </svg></span>
<span>Movies</span> <span>Películas</span>
</a> </a>
<a href="#" class="nav-link" data-page="series"> <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" <span class="nav-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
@@ -87,7 +82,7 @@
<path <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" /> 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> </svg></span>
<span>Settings</span> <span>Ajustes</span>
</a> </a>
</div> </div>
</nav> </nav>
@@ -97,7 +92,7 @@
<!-- Home (Dashboard) Page --> <!-- Home (Dashboard) Page -->
<div id="page-home" class="page active"> <div id="page-home" class="page active">
<!-- Content will be rendered by HomePage.js --> <!-- Content will be rendered by HomePage.js -->
<div class="loading-state">Loading Dashboard...</div> <div class="loading-state">Cargando inicio…</div>
</div> </div>
<!-- Live TV Page (formerly Home) --> <!-- 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"> <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" /> <path d="M3 13h2v-2H3v2zm0 4h2v-2H3v2zm0-8h2V7H3v2zm4 4h14v-2H7v2zm0 4h14v-2H7v2zM7 7v2h14V7H7z" />
</svg> </svg>
<span>Channels</span> <span>Canales</span>
</button> </button>
<!-- Overlay for drawer --> <!-- Overlay for drawer -->
<div class="channel-sidebar-overlay" id="channel-sidebar-overlay"></div> <div class="channel-sidebar-overlay" id="channel-sidebar-overlay"></div>
<!-- Expand button (visible when sidebar collapsed on desktop) --> <!-- 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"> <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" /> <path d="M8.59 16.59L10 18l6-6-6-6-1.41 1.41L13.17 12z" />
</svg> </svg>
@@ -126,11 +121,11 @@
<div class="sidebar-header"> <div class="sidebar-header">
<div class="sidebar-header-row"> <div class="sidebar-header-row">
<div class="search-wrapper"> <div class="search-wrapper">
<input type="text" id="channel-search" placeholder="Search channels..." class="search-input"> <input type="text" id="channel-search" placeholder="Buscar canales…" class="search-input">
<button type="button" class="search-clear" title="Clear search">&times;</button> <button type="button" class="search-clear" title="Limpiar búsqueda">&times;</button>
</div> </div>
<button id="sidebar-collapse-btn" class="btn btn-sm btn-ghost sidebar-collapse-btn" <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"> <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" /> <path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z" />
</svg> </svg>
@@ -138,9 +133,9 @@
</div> </div>
<div class="sidebar-controls"> <div class="sidebar-controls">
<select id="source-select" class="source-select"> <select id="source-select" class="source-select">
<option value="">All Sources</option> <option value="">Todas las fuentes</option>
</select> </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"> <span><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon">
<path <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" /> 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>
<div id="channel-list" class="channel-list"> <div id="channel-list" class="channel-list">
<div class="empty-state"> <div class="empty-state">
<p>No channels loaded</p> <p>Cargando canales…</p>
<p class="hint">Add a source in Settings to get started</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>
</div> </div>
</aside> </aside>
@@ -225,9 +220,9 @@
</svg> </svg>
</button> </button>
<div class="watch-captions-menu hidden" id="player-captions-menu"> <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"> <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> </div>
</div> </div>
@@ -257,7 +252,7 @@
<path <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" /> 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> </svg>
Copy Stream URL Copiar URL del stream
</button> </button>
</div> </div>
</div> </div>
@@ -267,18 +262,18 @@
<div class="player-overlay" id="player-overlay"> <div class="player-overlay" id="player-overlay">
<div class="overlay-content"> <div class="overlay-content">
<p>Select a channel to start watching</p> <p>Elige un canal para empezar</p>
</div> </div>
</div> </div>
<!-- Now Playing Overlay --> <!-- Now Playing Overlay -->
<div class="now-playing-overlay hidden" id="now-playing"> <div class="now-playing-overlay hidden" id="now-playing">
<div class="now-playing-info"> <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-title"></p>
<p class="program-time"></p> <p class="program-time"></p>
</div> </div>
<div class="up-next"> <div class="up-next">
<h4>Up Next</h4> <h4>A continuación</h4>
<ul id="up-next-list"></ul> <ul id="up-next-list"></ul>
</div> </div>
</div> </div>
@@ -290,24 +285,24 @@
<!-- Guide Page --> <!-- Guide Page -->
<div id="page-guide" class="page"> <div id="page-guide" class="page">
<div class="guide-header"> <div class="guide-header">
<h2>TV Guide</h2> <h2>Guía TV</h2>
<div class="guide-controls"> <div class="guide-controls">
<select id="epg-group-select" class="source-select"> <select id="epg-group-select" class="source-select">
<option value="">All Groups</option> <option value="">Todos los grupos</option>
</select> </select>
<div class="search-wrapper"> <div class="search-wrapper">
<input type="text" id="epg-search" placeholder="Search channels..." class="search-input"> <input type="text" id="epg-search" placeholder="Buscar canales…" class="search-input">
<button type="button" class="search-clear" title="Clear search">&times;</button> <button type="button" class="search-clear" title="Limpiar búsqueda">&times;</button>
</div> </div>
<button id="guide-prev" class="btn btn-ghost">&larr; Earlier</button> <button id="guide-prev" class="btn btn-ghost">&larr; Antes</button>
<span id="guide-date" class="guide-date">Today</span> <span id="guide-date" class="guide-date">Hoy</span>
<button id="guide-next" class="btn btn-ghost">Later &rarr;</button> <button id="guide-next" class="btn btn-ghost">Después &rarr;</button>
</div> </div>
</div> </div>
<div id="epg-grid" class="epg-grid"> <div id="epg-grid" class="epg-grid">
<div class="empty-state"> <div class="empty-state">
<p>No EPG data available</p> <p>No hay datos de guía (EPG)</p>
<p class="hint">Add an EPG source in Settings</p> <p class="hint">Añade una fuente EPG en Ajustes</p>
</div> </div>
</div> </div>
</div> </div>
@@ -315,29 +310,29 @@
<!-- Movies Page --> <!-- Movies Page -->
<div id="page-movies" class="page"> <div id="page-movies" class="page">
<div class="movies-header"> <div class="movies-header">
<h2>Movies</h2> <h2>Películas</h2>
<div class="movies-controls"> <div class="movies-controls">
<select id="movies-source-select" class="source-select"> <select id="movies-source-select" class="source-select">
<option value="">All Sources</option> <option value="">Todas las fuentes</option>
</select> </select>
<select id="movies-category-select" class="source-select"> <select id="movies-category-select" class="source-select">
<option value="">All Categories</option> <option value="">Todas las categorías</option>
</select> </select>
<div class="search-wrapper"> <div class="search-wrapper">
<input type="text" id="movies-search" placeholder="Search movies..." class="search-input"> <input type="text" id="movies-search" placeholder="Buscar películas…" class="search-input">
<button type="button" class="search-clear" title="Clear search">&times;</button> <button type="button" class="search-clear" title="Limpiar búsqueda">&times;</button>
</div> </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"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon">
<path <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" /> 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> </button>
</div> </div>
</div> </div>
<div id="movies-grid" class="movies-grid"> <div id="movies-grid" class="movies-grid">
<div class="empty-state"> <div class="empty-state">
<p>Loading movies...</p> <p>Cargando películas…</p>
</div> </div>
</div> </div>
</div> </div>
@@ -348,20 +343,20 @@
<h2>Series</h2> <h2>Series</h2>
<div class="series-controls"> <div class="series-controls">
<select id="series-source-select" class="source-select"> <select id="series-source-select" class="source-select">
<option value="">All Sources</option> <option value="">Todas las fuentes</option>
</select> </select>
<select id="series-category-select" class="source-select"> <select id="series-category-select" class="source-select">
<option value="">All Categories</option> <option value="">Todas las categorías</option>
</select> </select>
<div class="search-wrapper"> <div class="search-wrapper">
<input type="text" id="series-search" placeholder="Search series..." class="search-input"> <input type="text" id="series-search" placeholder="Buscar series…" class="search-input">
<button type="button" class="search-clear" title="Clear search">&times;</button> <button type="button" class="search-clear" title="Limpiar búsqueda">&times;</button>
</div> </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"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon">
<path <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" /> 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> </button>
</div> </div>
</div> </div>
@@ -369,12 +364,12 @@
<div class="series-content"> <div class="series-content">
<div id="series-grid" class="series-grid"> <div id="series-grid" class="series-grid">
<div class="empty-state"> <div class="empty-state">
<p>Loading series...</p> <p>Cargando series…</p>
</div> </div>
</div> </div>
<!-- Series details panel (seasons/episodes) --> <!-- Series details panel (seasons/episodes) -->
<div id="series-details" class="series-details hidden"> <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"> <div class="series-details-header">
<img id="series-poster" src="" alt="" class="series-poster"> <img id="series-poster" src="" alt="" class="series-poster">
<div class="series-info"> <div class="series-info">
@@ -390,59 +385,59 @@
<!-- Settings Page --> <!-- Settings Page -->
<div id="page-settings" class="page"> <div id="page-settings" class="page">
<div class="settings-container"> <div class="settings-container">
<h2>Settings</h2> <h2>Ajustes</h2>
<!-- Tabs --> <!-- Tabs -->
<div class="tabs"> <div class="tabs">
<button class="tab active" data-tab="sources">Sources</button> <button class="tab active" data-tab="sources">Fuentes</button>
<button class="tab" data-tab="player">Player</button> <button class="tab" data-tab="player">Reproductor</button>
<button class="tab" data-tab="transcode">Transcoding</button> <button class="tab" data-tab="transcode">Transcodificación</button>
<button class="tab" data-tab="content">Manage Content</button> <button class="tab" data-tab="content">Contenido</button>
<button class="tab" data-tab="users" id="users-tab" style="display:none;">Users</button> <button class="tab" data-tab="users" id="users-tab" style="display:none;">Usuarios</button>
</div> </div>
<!-- Sources Tab --> <!-- Sources Tab -->
<div id="tab-sources" class="tab-content active"> <div id="tab-sources" class="tab-content active">
<div class="source-section"> <div class="source-section">
<div class="section-header"> <div class="section-header">
<h3>Xtream Connections</h3> <h3>Conexiones Xtream</h3>
<button class="btn btn-primary" id="add-xtream">+ Add Xtream</button> <button class="btn btn-primary" id="add-xtream">+ Añadir Xtream</button>
</div> </div>
<div id="xtream-list" class="source-list"></div> <div id="xtream-list" class="source-list"></div>
</div> </div>
<div class="source-section"> <div class="source-section">
<div class="section-header"> <div class="section-header">
<h3>M3U Playlists</h3> <h3>Listas M3U</h3>
<button class="btn btn-primary" id="add-m3u">+ Add M3U</button> <button class="btn btn-primary" id="add-m3u">+ Añadir M3U</button>
</div> </div>
<div id="m3u-list" class="source-list"></div> <div id="m3u-list" class="source-list"></div>
</div> </div>
<div class="source-section"> <div class="source-section">
<div class="section-header"> <div class="section-header">
<h3>EPG Sources</h3> <h3>Fuentes EPG</h3>
<button class="btn btn-primary" id="add-epg">+ Add EPG</button> <button class="btn btn-primary" id="add-epg">+ Añadir EPG</button>
</div> </div>
<div id="epg-list" class="source-list"></div> <div id="epg-list" class="source-list"></div>
</div> </div>
<div class="source-section"> <div class="source-section">
<h3>EPG Data Settings</h3> <h3>Datos EPG</h3>
<div class="setting-item"> <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"> <select id="epg-refresh-interval" class="form-input">
<option value="1">1 hour</option> <option value="1">1 hora</option>
<option value="2">2 hours</option> <option value="2">2 horas</option>
<option value="4">4 hours</option> <option value="4">4 horas</option>
<option value="12">12 hours</option> <option value="12">12 horas</option>
<option value="24" selected>24 hours</option> <option value="24" selected>24 horas</option>
<option value="0">Manual only</option> <option value="0">Solo manual</option>
</select> </select>
</div> </div>
<div class="setting-item"> <div class="setting-item">
<span class="setting-label">Last Refreshed:</span> <span class="setting-label">Última actualización:</span>
<span id="epg-last-refreshed" class="hint">Never</span> <span id="epg-last-refreshed" class="hint">Nunca</span>
</div> </div>
<p class="hint">Use the <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" <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;"> class="icon" style="width: 1em; height: 1em; vertical-align: middle;">
@@ -455,32 +450,32 @@
<!-- Player Tab --> <!-- Player Tab -->
<div id="tab-player" class="tab-content"> <div id="tab-player" class="tab-content">
<div class="settings-section"> <div class="settings-section">
<h3>Keyboard Controls</h3> <h3>Teclado</h3>
<div class="setting-item"> <div class="setting-item">
<label class="setting-toggle"> <label class="setting-toggle">
<input type="checkbox" id="setting-arrow-keys" checked> <input type="checkbox" id="setting-arrow-keys" checked>
<span class="toggle-slider"></span> <span class="toggle-slider"></span>
</label> </label>
<div class="setting-info"> <div class="setting-info">
<span class="setting-label">Arrow keys change channels</span> <span class="setting-label">Las flechas cambian de canal</span>
<span class="setting-hint">When enabled, ↑/↓ change channels and ←/→ adjust volume</span> <span class="setting-hint">Si está activo: ↑/↓ cambian canal y ←/→ ajustan volumen</span>
</div> </div>
</div> </div>
</div> </div>
<div class="settings-section"> <div class="settings-section">
<h3>Now Playing Overlay</h3> <h3>Overlay «Reproduciendo»</h3>
<div class="setting-item"> <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" <input type="number" id="setting-overlay-duration" class="form-input setting-number" min="1" max="30"
value="5"> value="5">
</div> </div>
</div> </div>
<div class="settings-section"> <div class="settings-section">
<h3>Volume Settings</h3> <h3>Volumen</h3>
<div class="setting-item"> <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"> <div class="volume-slider-container">
<input type="range" id="setting-default-volume" class="volume-slider" min="0" max="100" value="80"> <input type="range" id="setting-default-volume" class="volume-slider" min="0" max="100" value="80">
<span id="volume-value">80%</span> <span id="volume-value">80%</span>
@@ -492,36 +487,36 @@
<span class="toggle-slider"></span> <span class="toggle-slider"></span>
</label> </label>
<div class="setting-info"> <div class="setting-info">
<span class="setting-label">Remember Last Volume</span> <span class="setting-label">Recordar último volumen</span>
<span class="setting-hint">When enabled, volume persists between sessions</span> <span class="setting-hint">Si está activo, el volumen se conserva entre sesiones</span>
</div> </div>
</div> </div>
</div> </div>
<div class="settings-section"> <div class="settings-section">
<h3>Playback</h3> <h3>Reproducción</h3>
<div class="setting-item"> <div class="setting-item">
<label class="setting-toggle"> <label class="setting-toggle">
<input type="checkbox" id="setting-autoplay-next"> <input type="checkbox" id="setting-autoplay-next">
<span class="toggle-slider"></span> <span class="toggle-slider"></span>
</label> </label>
<div class="setting-info"> <div class="setting-info">
<span class="setting-label">Auto-play Next Episode</span> <span class="setting-label">Autoplay del siguiente episodio</span>
<span class="setting-hint">Automatically play the next episode when one ends</span> <span class="setting-hint">Cuando termine un episodio, pasa automáticamente al siguiente</span>
</div> </div>
</div> </div>
</div> </div>
<div class="settings-section"> <div class="settings-section">
<h3>Keyboard Shortcuts</h3> <h3>Atajos de teclado</h3>
<div class="shortcuts-grid"> <div class="shortcuts-grid">
<div class="shortcut"><kbd>↑</kbd> / <kbd>↓</kbd> Channel up/down (or volume)</div> <div class="shortcut"><kbd>↑</kbd> / <kbd>↓</kbd> Canal anterior/siguiente (o volumen)</div>
<div class="shortcut"><kbd>←</kbd> / <kbd>→</kbd> Volume down/up (when arrows = channels)</div> <div class="shortcut"><kbd>←</kbd> / <kbd>→</kbd> Bajar/subir volumen (si flechas = canales)</div>
<div class="shortcut"><kbd>Space</kbd> / <kbd>K</kbd> Play/Pause</div> <div class="shortcut"><kbd>Espacio</kbd> / <kbd>K</kbd> Reproducir / Pausa</div>
<div class="shortcut"><kbd>F</kbd> Fullscreen</div> <div class="shortcut"><kbd>F</kbd> Pantalla completa</div>
<div class="shortcut"><kbd>M</kbd> Mute/Unmute</div> <div class="shortcut"><kbd>M</kbd> Silenciar / activar sonido</div>
<div class="shortcut"><kbd>I</kbd> Toggle info overlay</div> <div class="shortcut"><kbd>I</kbd> Mostrar u ocultar información</div>
<div class="shortcut"><kbd>PgUp</kbd> / <kbd>PgDn</kbd> Channel up/down</div> <div class="shortcut"><kbd>Re Pág</kbd> / <kbd>Av Pág</kbd> Canal anterior/siguiente</div>
</div> </div>
</div> </div>
</div> </div>
@@ -529,14 +524,14 @@
<!-- Transcoding Tab --> <!-- Transcoding Tab -->
<div id="tab-transcode" class="tab-content"> <div id="tab-transcode" class="tab-content">
<div class="settings-section"> <div class="settings-section">
<h3>Detected Hardware</h3> <h3>Hardware detectado</h3>
<div id="hw-info-container" class="hw-info-container"> <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> </div>
<div class="settings-section"> <div class="settings-section">
<h3>Encoder Settings</h3> <h3>Codificador</h3>
<div class="setting-item"> <div class="setting-item">
<div class="setting-info" style="flex: 1;"> <div class="setting-info" style="flex: 1;">
<span class="setting-label">Hardware Encoder</span> <span class="setting-label">Hardware Encoder</span>
@@ -678,12 +673,12 @@
<div class="setting-item"> <div class="setting-item">
<div class="setting-info" style="flex: 1;"> <div class="setting-info" style="flex: 1;">
<span class="setting-label">Stream Output Format</span> <span class="setting-label">Stream Output Format</span>
<span class="setting-hint">Container format for Xtream streams. Try TS if you experience buffering <span class="setting-hint">Para Dispatcharr: elige <strong>TS</strong> (aunque la URL lleve .m3u8, suele
issues.</span> ser video MPEG-TS; HLS falla en el navegador).</span>
</div> </div>
<select id="setting-stream-format-tc" class="source-select" style="width: auto; min-width: 200px;"> <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) — recomendado con Dispatcharr</option>
<option value="ts">MPEG-TS (ts) - Better buffer</option> <option value="m3u8">HLS (m3u8) — solo si el proveedor es manifiesto real</option>
</select> </select>
</div> </div>
</div> </div>
@@ -734,30 +729,30 @@
<div class="content-browser"> <div class="content-browser">
<div class="content-browser-header"> <div class="content-browser-header">
<div class="content-type-toggle"> <div class="content-type-toggle">
<button class="btn btn-sm active" id="content-type-channels">Channels</button> <button class="btn btn-sm active" id="content-type-channels">Canales</button>
<button class="btn btn-sm" id="content-type-movies">Movies</button> <button class="btn btn-sm" id="content-type-movies">Películas</button>
<button class="btn btn-sm" id="content-type-series">Series</button> <button class="btn btn-sm" id="content-type-series">Series</button>
</div> </div>
<select id="content-source-select" class="source-select"> <select id="content-source-select" class="source-select">
<option value="">Select a source...</option> <option value="">Selecciona una fuente…</option>
</select> </select>
<div class="search-wrapper"> <div class="search-wrapper">
<input type="text" id="content-search" placeholder="Search groups/channels..." class="search-input"> <input type="text" id="content-search" placeholder="Buscar grupos o canales…" class="search-input">
<button type="button" class="search-clear" title="Clear search">&times;</button> <button type="button" class="search-clear" title="Limpiar búsqueda">&times;</button>
</div> </div>
<div class="content-actions"> <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-show-all">Mostrar todo</button>
<button class="btn btn-sm btn-secondary" id="content-hide-all">Hide All</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" <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"> viewBox="0 0 24 24" fill="currentColor" class="icon">
<path <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" /> 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>
</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"> <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> </div>
</div> </div>
@@ -765,47 +760,47 @@
<!-- Users Tab (Admin Only) --> <!-- Users Tab (Admin Only) -->
<div id="tab-users" class="tab-content"> <div id="tab-users" class="tab-content">
<div class="settings-section"> <div class="settings-section">
<h3>User Management</h3> <h3>Usuarios</h3>
<p class="hint">Manage user accounts and permissions</p> <p class="hint">Gestiona cuentas y permisos</p>
<div class="user-list-container"> <div class="user-list-container">
<table class="user-table"> <table class="user-table">
<thead> <thead>
<tr> <tr>
<th>Username</th> <th>Usuario</th>
<th>Email</th> <th>Correo</th>
<th>Role</th> <th>Rol</th>
<th>Created</th> <th>Creado</th>
<th>Actions</th> <th>Acciones</th>
</tr> </tr>
</thead> </thead>
<tbody id="user-list"> <tbody id="user-list">
<tr> <tr>
<td colspan="4" class="hint">Loading users...</td> <td colspan="4" class="hint">Cargando usuarios…</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="add-user-section"> <div class="add-user-section">
<h4>Add New User</h4> <h4>Añadir usuario</h4>
<form id="add-user-form" class="user-form"> <form id="add-user-form" class="user-form">
<div class="form-group"> <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> <input type="text" id="new-username" name="username" class="form-input" required>
</div> </div>
<div class="form-group"> <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"> <input type="password" id="new-password" name="password" class="form-input" required minlength="6">
</div> </div>
<div class="form-group"> <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> <select id="new-role" name="role" class="form-input" required>
<option value="viewer">Viewer</option> <option value="viewer">Espectador</option>
<option value="admin">Admin</option> <option value="admin">Administrador</option>
</select> </select>
</div> </div>
<button type="submit" class="btn btn-primary">Add User</button> <button type="submit" class="btn btn-primary">Añadir usuario</button>
</form> </form>
</div> </div>
</div> </div>
@@ -823,7 +818,7 @@
<div class="watch-overlay" id="watch-overlay"> <div class="watch-overlay" id="watch-overlay">
<!-- Top Bar: Back + Title --> <!-- Top Bar: Back + Title -->
<div class="watch-top-bar vod-header"> <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"> <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" /> <path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
</svg> </svg>
@@ -901,9 +896,9 @@
</svg> </svg>
</button> </button>
<div class="watch-captions-menu hidden" id="watch-captions-menu"> <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"> <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> </div>
</div> </div>
@@ -932,7 +927,7 @@
<path <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" /> 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> </svg>
Copy Stream URL Copiar URL del stream
</button> </button>
</div> </div>
</div> </div>
@@ -941,7 +936,7 @@
<!-- Scroll Indicator --> <!-- Scroll Indicator -->
<div class="watch-scroll-hint" id="watch-scroll-hint"> <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"> <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" /> <path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z" />
</svg> </svg>
@@ -951,12 +946,12 @@
<!-- Next Episode Panel --> <!-- Next Episode Panel -->
<div class="watch-next-episode hidden" id="watch-next-episode"> <div class="watch-next-episode hidden" id="watch-next-episode">
<div class="next-info"> <div class="next-info">
<p class="next-label">Up Next</p> <p class="next-label">Siguiente</p>
<h4 id="next-episode-title"></h4> <h4 id="next-episode-title"></h4>
</div> </div>
<span class="next-countdown" id="next-countdown">10</span> <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-primary btn-sm" id="next-play-now">Reproducir ya</button>
<button class="btn btn-ghost btn-sm" id="next-cancel">Cancel</button> <button class="btn btn-ghost btn-sm" id="next-cancel">Cancelar</button>
</div> </div>
</section> </section>
@@ -978,7 +973,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon">
<path d="M8 5v14l11-7z" /> <path d="M8 5v14l11-7z" />
</svg> </svg>
<span id="watch-play-btn-text">Play</span> <span id="watch-play-btn-text">Reproducir</span>
</button> </button>
<button class="btn btn-ghost" id="watch-favorite-btn"> <button class="btn btn-ghost" id="watch-favorite-btn">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
@@ -991,7 +986,7 @@
<path <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" /> 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> </svg>
<span>Favorite</span> <span>Favorito</span>
</button> </button>
</div> </div>
</div> </div>
@@ -999,7 +994,7 @@
<!-- Movies: Recommended Section --> <!-- Movies: Recommended Section -->
<div class="watch-recommended hidden" id="watch-recommended"> <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"> <div class="watch-recommended-grid" id="watch-recommended-grid">
<!-- Movie cards inserted dynamically --> <!-- Movie cards inserted dynamically -->
</div> </div>
@@ -1007,7 +1002,7 @@
<!-- Series: Seasons & Episodes --> <!-- Series: Seasons & Episodes -->
<div class="watch-episodes hidden" id="watch-episodes"> <div class="watch-episodes hidden" id="watch-episodes">
<h3>Episodes</h3> <h3>Episodios</h3>
<div class="watch-seasons" id="watch-seasons"> <div class="watch-seasons" id="watch-seasons">
<!-- Season accordions inserted dynamically --> <!-- Season accordions inserted dynamically -->
</div> </div>
@@ -1022,7 +1017,7 @@
<div id="modal" class="modal"> <div id="modal" class="modal">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h3 id="modal-title">Modal</h3> <h3 id="modal-title">Ventana</h3>
<button class="modal-close">&times;</button> <button class="modal-close">&times;</button>
</div> </div>
<div id="modal-body" class="modal-body"></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" <button class="context-item" data-action="play"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
fill="currentColor" class="icon"> fill="currentColor" class="icon">
<path d="M8 5v14l11-7z" /> <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" <button class="context-item" data-action="hide"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
fill="currentColor" class="icon"> fill="currentColor" class="icon">
<path <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" /> 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> </div>
<!-- Edit User Modal --> <!-- Edit User Modal -->
<div class="modal-overlay" id="edit-user-modal"> <div class="modal-overlay" id="edit-user-modal">
<div class="modal"> <div class="modal">
<div class="modal-header"> <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> <button class="modal-close" id="edit-user-close">&times;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
@@ -1055,41 +1050,41 @@
<input type="hidden" id="edit-user-id"> <input type="hidden" id="edit-user-id">
<div class="modal-form-group"> <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> <input type="text" id="edit-username" name="username" class="form-input" required>
</div> </div>
<div class="modal-form-group"> <div class="modal-form-group">
<label for="edit-email">Email</label> <label for="edit-email">Correo</label>
<input type="text" id="edit-email" class="form-input" readonly disabled placeholder="No email associated"> <input type="text" id="edit-email" class="form-input" readonly disabled placeholder="Sin correo asociado">
</div> </div>
<div class="modal-form-group"> <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> <select id="edit-role" name="role" class="form-input" required>
<option value="viewer">Viewer</option> <option value="viewer">Espectador</option>
<option value="admin">Admin</option> <option value="admin">Administrador</option>
</select> </select>
</div> </div>
<div class="modal-form-group"> <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" <input type="password" id="edit-password" name="password" class="form-input"
placeholder="Leave blank to keep current"> placeholder="Dejar en blanco para no cambiar">
<p class="hint" id="edit-password-hint" style="margin-top: 4px;">Optional. Leave blank to keep unchanged. <p class="hint" id="edit-password-hint" style="margin-top: 4px;">Opcional. Déjalo en blanco para mantener la actual.
</p> </p>
</div> </div>
<div class="modal-form-group hidden" id="oidc-info-group"> <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" <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> 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> </div>
</form> </form>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button class="btn btn-ghost" id="edit-user-cancel">Cancel</button> <button class="btn btn-ghost" id="edit-user-cancel">Cancelar</button>
<button class="btn btn-primary" id="edit-user-save">Save Changes</button> <button class="btn btn-primary" id="edit-user-save">Guardar cambios</button>
</div> </div>
</div> </div>
</div> </div>
@@ -1097,7 +1092,8 @@
<!-- JavaScript --> <!-- JavaScript -->
<script src="/js/icons.js"></script> <script src="/js/icons.js"></script>
<script src="/js/api.js?v=2"></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/ChannelList.js?v=3"></script>
<script src="/js/components/SourceManager.js?v=2"></script> <script src="/js/components/SourceManager.js?v=2"></script>
<script src="/js/components/EpgGuide.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/Guide.js?v=2"></script>
<script src="/js/pages/MoviesPage.js?v=4"></script> <script src="/js/pages/MoviesPage.js?v=4"></script>
<script src="/js/pages/SeriesPage.js?v=1"></script> <script src="/js/pages/SeriesPage.js?v=1"></script>
<script src="/js/pages/Settings.js?v=2"></script> <script src="/js/pages/Settings.js?v=3"></script>
<script src="/js/pages/WatchPage.js?v=1"></script> <script src="/js/pages/WatchPage.js?v=3"></script>
<script src="/js/app.js?v=4"></script> <script src="/js/app.js?v=5"></script>
<script> <script>
// Check for SSO token in URL // Check for SSO token in URL
(function () { (function () {
+1 -1
View File
@@ -136,7 +136,7 @@ const API = {
seriesInfo: (sourceId, seriesId) => seriesInfo: (sourceId, seriesId) =>
API.request('GET', `/proxy/xtream/${sourceId}/series_info?series_id=${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}`), 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}`) 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 { class App {
@@ -150,6 +150,11 @@ class App {
// Initialize home page first (it's needed for channel list) // Initialize home page first (it's needed for channel list)
await this.pages.home.init(); 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) // Preload EPG data in background (non-blocking)
// This ensures EPG info is available on Live TV page without visiting Guide first // This ensures EPG info is available on Live TV page without visiting Guide first
this.epgGuide.loadEpg().catch(err => { this.epgGuide.loadEpg().catch(err => {
@@ -161,7 +166,7 @@ class App {
const initialPage = hash && this.pages[hash] ? hash : 'home'; const initialPage = hash && this.pages[hash] ? hash : 'home';
this.navigateTo(initialPage, true); // true = replace history (don't add) this.navigateTo(initialPage, true); // true = replace history (don't add)
console.log('NodeCast TV initialized'); console.log('KiraTV initialized');
} }
async checkAuth() { async checkAuth() {
+3 -3
View File
@@ -46,7 +46,7 @@ class ChannelList {
*/ */
loadCollapsedState() { loadCollapsedState() {
try { try {
const saved = localStorage.getItem('nodecast_tv_collapsed_groups'); const saved = localStorage.getItem('kiratv_collapsed_groups');
if (saved) { if (saved) {
this.collapsedGroups = new Set(JSON.parse(saved)); this.collapsedGroups = new Set(JSON.parse(saved));
this._hasCollapsedState = true; this._hasCollapsedState = true;
@@ -64,7 +64,7 @@ class ChannelList {
*/ */
saveCollapsedState() { saveCollapsedState() {
try { try {
localStorage.setItem('nodecast_tv_collapsed_groups', JSON.stringify([...this.collapsedGroups])); localStorage.setItem('kiratv_collapsed_groups', JSON.stringify([...this.collapsedGroups]));
} catch (err) { } catch (err) {
console.error('Error saving collapsed state:', err); console.error('Error saving collapsed state:', err);
} }
@@ -1159,7 +1159,7 @@ class ChannelList {
let streamUrl; let streamUrl;
if (channel.sourceType === 'xtream') { if (channel.sourceType === 'xtream') {
// Get stream format from player settings (server-side) or fallback // 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); const result = await API.proxy.xtream.getStreamUrl(channel.sourceId, channel.streamId, 'live', streamFormat);
streamUrl = result.url; streamUrl = result.url;
} else { } else {
+99 -17
View File
@@ -8,6 +8,8 @@ class SourceManager {
this.xtreamList = document.getElementById('xtream-list'); this.xtreamList = document.getElementById('xtream-list');
this.m3uList = document.getElementById('m3u-list'); this.m3uList = document.getElementById('m3u-list');
this.epgList = document.getElementById('epg-list'); this.epgList = document.getElementById('epg-list');
/** @type {Record<number, string>} id → username (admin, for labels) */
this.userNamesById = {};
// Content browser state // Content browser state
this.contentType = 'channels'; // 'channels' or 'movies' this.contentType = 'channels'; // 'channels' or 'movies'
@@ -20,6 +22,29 @@ class SourceManager {
this.init(); 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() { init() {
// Add source buttons // Add source buttons
document.getElementById('add-xtream').addEventListener('click', () => this.showAddModal('xtream')); document.getElementById('add-xtream').addEventListener('click', () => this.showAddModal('xtream'));
@@ -112,9 +137,20 @@ class SourceManager {
try { try {
const sources = await API.sources.getAll(); const sources = await API.sources.getAll();
this.renderSourceList(this.xtreamList, sources.filter(s => s.type === 'xtream'), 'xtream'); const admin = await this.isAdminSession();
this.renderSourceList(this.m3uList, sources.filter(s => s.type === 'm3u'), 'm3u'); this.userNamesById = {};
this.renderSourceList(this.epgList, sources.filter(s => s.type === 'epg'), 'epg'); 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) { } catch (err) {
console.error('Error loading sources:', err); console.error('Error loading sources:', err);
} }
@@ -123,7 +159,7 @@ class SourceManager {
/** /**
* Render source list * Render source list
*/ */
renderSourceList(container, sources, type) { renderSourceList(container, sources, type, isAdmin = false) {
if (sources.length === 0) { if (sources.length === 0) {
container.innerHTML = `<p class="hint">No ${type.toUpperCase()} sources configured</p>`; container.innerHTML = `<p class="hint">No ${type.toUpperCase()} sources configured</p>`;
return; return;
@@ -131,12 +167,21 @@ class SourceManager {
const icons = { xtream: Icons.live, m3u: Icons.guide, epg: Icons.series }; 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 => ` container.innerHTML = sources.map(source => `
<div class="source-item ${source.enabled ? '' : 'disabled'}" data-id="${source.id}"> <div class="source-item ${source.enabled ? '' : 'disabled'}" data-id="${source.id}">
<span class="source-icon">${icons[type]}</span> <span class="source-icon">${icons[type]}</span>
<div class="source-info"> <div class="source-info">
<div class="source-name">${source.name}</div> <div class="source-name">${esc(source.name)}</div>
<div class="source-url">${source.url}</div> <div class="source-url">${esc(source.url)}</div>
${ownerLine}
</div> </div>
<div class="source-actions"> <div class="source-actions">
<button class="btn btn-sm btn-secondary" data-action="refresh" title="Refresh Data">${Icons.refresh}</button> <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 * Show add source modal
*/ */
showAddModal(type) { async showAddModal(type) {
const modal = document.getElementById('modal'); const modal = document.getElementById('modal');
const title = document.getElementById('modal-title'); const title = document.getElementById('modal-title');
const body = document.getElementById('modal-body'); 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' }; const titles = { xtream: 'Add Xtream Connection', m3u: 'Add M3U Playlist', epg: 'Add EPG Source' };
title.textContent = titles[type]; title.textContent = titles[type];
body.innerHTML = this.getSourceForm(type); body.innerHTML = await this.getSourceForm(type);
footer.innerHTML = ` footer.innerHTML = `
<button class="btn btn-secondary" id="modal-cancel">Cancel</button> <button class="btn btn-secondary" id="modal-cancel">Cancel</button>
@@ -202,7 +247,7 @@ class SourceManager {
const footer = document.getElementById('modal-footer'); const footer = document.getElementById('modal-footer');
title.textContent = `Edit ${type.toUpperCase()} Source`; title.textContent = `Edit ${type.toUpperCase()} Source`;
body.innerHTML = this.getSourceForm(type, source); body.innerHTML = await this.getSourceForm(type, source);
footer.innerHTML = ` footer.innerHTML = `
<button class="btn btn-secondary" id="modal-cancel">Cancel</button> <button class="btn btn-secondary" id="modal-cancel">Cancel</button>
@@ -222,11 +267,32 @@ class SourceManager {
/** /**
* Get source form HTML * 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 = ` const nameField = `
<div class="form-group"> <div class="form-group">
<label for="source-name">Name</label> <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> </div>
`; `;
@@ -235,27 +301,28 @@ class SourceManager {
<label for="source-url">${type === 'xtream' ? 'Server URL' : 'URL'}</label> <label for="source-url">${type === 'xtream' ? 'Server URL' : 'URL'}</label>
<input type="text" id="source-url" class="form-input" <input type="text" id="source-url" class="form-input"
placeholder="${type === 'xtream' ? 'http://server.com:port' : 'https://example.com/playlist.m3u'}" placeholder="${type === 'xtream' ? 'http://server.com:port' : 'https://example.com/playlist.m3u'}"
value="${source.url || ''}"> value="${esc(source.url)}">
</div> </div>
`; `;
if (type === 'xtream') { if (type === 'xtream') {
const pwVal = source.password && !String(source.password).includes('•') ? esc(source.password) : '';
return ` return `
${ownerField}
${nameField} ${nameField}
${urlField} ${urlField}
<div class="form-group"> <div class="form-group">
<label for="source-username">Username</label> <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>
<div class="form-group"> <div class="form-group">
<label for="source-password">Password</label> <label for="source-password">Password</label>
<input type="password" id="source-password" class="form-input" <input type="password" id="source-password" class="form-input" value="${pwVal}">
value="${source.password && !source.password.includes('•') ? source.password : ''}">
</div> </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'); document.getElementById('modal').classList.remove('active');
await this.loadSources(); await this.loadSources();
@@ -329,6 +404,13 @@ class SourceManager {
data.username = username; data.username = username;
if (password) data.password = password; 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); await API.sources.update(id, data);
document.getElementById('modal').classList.remove('active'); document.getElementById('modal').classList.remove('active');
+71 -25
View File
@@ -28,6 +28,9 @@ class VideoPlayer {
this.isUsingProxy = false; this.isUsingProxy = false;
this.currentUrl = null; this.currentUrl = null;
this.settingsLoaded = false; this.settingsLoaded = false;
this.switchInProgress = false;
this.queuedSwitch = null;
this.lastSwitchAt = 0;
// Settings - start with defaults, load from server async // Settings - start with defaults, load from server async
this.settings = this.getDefaultSettings(); this.settings = this.getDefaultSettings();
@@ -53,7 +56,7 @@ class VideoPlayer {
forceTranscode: false, forceTranscode: false,
forceRemux: false, forceRemux: false,
autoTranscode: true, autoTranscode: true,
streamFormat: 'm3u8', streamFormat: 'ts',
epgRefreshInterval: '24' epgRefreshInterval: '24'
}; };
} }
@@ -71,7 +74,7 @@ class VideoPlayer {
console.warn('[Player] Failed to load settings from server, using defaults:', err.message); console.warn('[Player] Failed to load settings from server, using defaults:', err.message);
// Fall back to localStorage for backwards compatibility // Fall back to localStorage for backwards compatibility
try { try {
const saved = localStorage.getItem('nodecast_tv_player_settings'); const saved = localStorage.getItem('kiratv_player_settings');
if (saved) { if (saved) {
this.settings = { ...this.getDefaultSettings(), ...JSON.parse(saved) }; this.settings = { ...this.getDefaultSettings(), ...JSON.parse(saved) };
console.log('[Player] Settings loaded from localStorage (fallback)'); console.log('[Player] Settings loaded from localStorage (fallback)');
@@ -93,7 +96,7 @@ class VideoPlayer {
console.error('[Player] Error saving settings to server:', err); console.error('[Player] Error saving settings to server:', err);
// Also save to localStorage as backup // Also save to localStorage as backup
try { try {
localStorage.setItem('nodecast_tv_player_settings', JSON.stringify(this.settings)); localStorage.setItem('kiratv_player_settings', JSON.stringify(this.settings));
} catch (localErr) { } catch (localErr) {
console.error('[Player] Error saving to localStorage:', localErr); console.error('[Player] Error saving to localStorage:', localErr);
} }
@@ -813,37 +816,58 @@ class VideoPlayer {
* Start a HLS transcode session * Start a HLS transcode session
*/ */
async startTranscodeSession(url, options = {}) { async startTranscodeSession(url, options = {}) {
try {
console.log('[Player] Starting HLS transcode session...', options); console.log('[Player] Starting HLS transcode session...', options);
let lastError = null;
// Retry a couple of times to ride out provider-side 5xx/rate-limit spikes.
for (let attempt = 1; attempt <= 3; attempt++) {
try {
const res = await fetch('/api/transcode/session', { const res = await fetch('/api/transcode/session', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url, ...options }) body: JSON.stringify({ url, ...options })
}); });
if (!res.ok) throw new Error('Failed to start session'); if (!res.ok) {
throw new Error(`Failed to start session (${res.status})`);
}
const session = await res.json(); const session = await res.json();
this.currentSessionId = session.sessionId; this.currentSessionId = session.sessionId;
return session.playlistUrl; return session.playlistUrl;
} catch (err) { } catch (err) {
console.error('[Player] Session start failed:', err); lastError = err;
// Fallback to direct transcode if session fails console.warn(`[Player] Session start failed (attempt ${attempt}/3):`, err?.message || err);
return `/api/transcode?url=${encodeURIComponent(url)}`; 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 * Stop and cleanup current transcode session
*/ */
async stopTranscodeSession() { async stopTranscodeSession() {
if (this.currentSessionId) { if (!this.currentSessionId) return;
console.log('[Player] Stopping transcode session:', this.currentSessionId);
const sessionId = this.currentSessionId;
this.currentSessionId = null;
console.log('[Player] Stopping transcode session:', sessionId);
try { try {
// Fire and forget cleanup const controller = new AbortController();
fetch(`/api/transcode/${this.currentSessionId}`, { method: 'DELETE' }); const timer = setTimeout(() => controller.abort(), 1500);
await fetch(`/api/transcode/${sessionId}`, {
method: 'DELETE',
signal: controller.signal
});
clearTimeout(timer);
} catch (err) { } catch (err) {
if (err?.name !== 'AbortError') {
console.error('Failed to stop session:', err); console.error('Failed to stop session:', err);
} }
this.currentSessionId = null;
} }
} }
@@ -851,14 +875,26 @@ class VideoPlayer {
* Play a channel * Play a channel
*/ */
async play(channel, streamUrl) { async play(channel, streamUrl) {
// Avoid overlapping channel changes; keep only the latest zap request.
if (this.switchInProgress) {
this.queuedSwitch = { channel, streamUrl };
return;
}
this.switchInProgress = true;
this.currentChannel = channel; this.currentChannel = channel;
try { try {
// Stop any WatchPage playback (movies/series) before starting Live TV // Stop any WatchPage playback (movies/series) before starting Live TV
window.app?.pages?.watch?.stop?.(); window.app?.pages?.watch?.stop?.();
// Stop current playback // Stop current playback (sends DELETE to worker → FFmpeg dies → Dispatcharr frees the slot)
this.stop(); 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'); this.updateTranscodeStatus('hidden');
// Hide "select a channel" overlay // Hide "select a channel" overlay
@@ -932,15 +968,18 @@ class VideoPlayer {
window.dispatchEvent(new CustomEvent('channelChanged', { detail: channel })); window.dispatchEvent(new CustomEvent('channelChanged', { detail: channel }));
return; return;
} else if (info.needsRemux) { } else if (info.needsRemux) {
// Raw .ts container - use remux // Algunos streams TS fallan en remux directo (ffmpeg mp4 mux errors).
console.log('[Player] Auto: Using remux (.ts container)'); // En ese caso es mas estable usar sesion HLS con video copy.
this.updateTranscodeStatus('remuxing', 'Remux (Auto)'); console.log('[Player] Auto: Using HLS transcode session (copy for remux-needed stream)');
const remuxUrl = `/api/remux?url=${encodeURIComponent(streamUrl)}`; this.updateTranscodeStatus('transcoding', 'Transcoding (Auto)');
this.currentUrl = remuxUrl; const playlistUrl = await this.startTranscodeSession(streamUrl, {
this.video.src = remuxUrl; videoMode: 'copy',
this.video.play().catch(e => { videoCodec: info.video,
if (e.name !== 'AbortError') console.log('[Player] Autoplay prevented:', e); audioCodec: info.audio,
audioChannels: info.audioChannels
}); });
this.currentUrl = playlistUrl;
this.playHls(playlistUrl);
this.updateNowPlaying(channel); this.updateNowPlaying(channel);
this.showNowPlayingOverlay(); this.showNowPlayingOverlay();
this.fetchEpgData(channel); this.fetchEpgData(channel);
@@ -1183,6 +1222,13 @@ class VideoPlayer {
} catch (err) { } catch (err) {
console.error('Error playing channel:', err); console.error('Error playing channel:', err);
this.showError('Failed to play channel'); this.showError('Failed to play channel');
} finally {
this.switchInProgress = false;
if (this.queuedSwitch) {
const next = this.queuedSwitch;
this.queuedSwitch = null;
this.play(next.channel, next.streamUrl);
}
} }
} }
@@ -1387,9 +1433,9 @@ class VideoPlayer {
/** /**
* Stop playback * Stop playback
*/ */
stop() { async stop() {
// Stop any running transcode session first // Stop any running transcode session first
this.stopTranscodeSession(); await this.stopTranscodeSession();
if (this.hls) { if (this.hls) {
this.hls.destroy(); 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 (forceTranscodeToggle) forceTranscodeToggle.checked = s.forceTranscode === true;
if (forceVideoTranscodeToggle) forceVideoTranscodeToggle.checked = s.forceVideoTranscode === true; if (forceVideoTranscodeToggle) forceVideoTranscodeToggle.checked = s.forceVideoTranscode === true;
if (forceRemuxToggle) forceRemuxToggle.checked = s.forceRemux || false; 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 (userAgentSelect) userAgentSelect.value = s.userAgentPreset || 'chrome';
if (userAgentCustomInput) userAgentCustomInput.value = s.userAgentCustom || ''; if (userAgentCustomInput) userAgentCustomInput.value = s.userAgentCustom || '';
if (customUaContainer) { if (customUaContainer) {
@@ -575,7 +575,7 @@ class SettingsPage {
if (forceRemuxToggle) forceRemuxToggle.checked = s.forceRemux || false; if (forceRemuxToggle) forceRemuxToggle.checked = s.forceRemux || false;
if (autoTranscodeToggle) autoTranscodeToggle.checked = s.autoTranscode || false; if (autoTranscodeToggle) autoTranscodeToggle.checked = s.autoTranscode || false;
if (epgRefreshSelect) epgRefreshSelect.value = s.epgRefreshInterval || '24'; if (epgRefreshSelect) epgRefreshSelect.value = s.epgRefreshInterval || '24';
if (streamFormatSelect) streamFormatSelect.value = s.streamFormat || 'm3u8'; if (streamFormatSelect) streamFormatSelect.value = s.streamFormat || 'ts';
// User-Agent settings // User-Agent settings
const userAgentSelect = document.getElementById('setting-user-agent'); const userAgentSelect = document.getElementById('setting-user-agent');
+2 -2
View File
@@ -611,7 +611,7 @@ class WatchPage {
} }
setVolumeFromStorage() { setVolumeFromStorage() {
const savedVolume = localStorage.getItem('nodecast-volume') || '80'; const savedVolume = localStorage.getItem('kiratv-volume') || '80';
this.video.volume = parseInt(savedVolume) / 100; this.video.volume = parseInt(savedVolume) / 100;
if (this.volumeSlider) this.volumeSlider.value = savedVolume; if (this.volumeSlider) this.volumeSlider.value = savedVolume;
} }
@@ -677,7 +677,7 @@ class WatchPage {
if (this.video) { if (this.video) {
this.video.volume = value / 100; this.video.volume = value / 100;
this.video.muted = false; this.video.muted = false;
localStorage.setItem('nodecast-volume', value); localStorage.setItem('kiratv-volume', value);
this.updateVolumeUI(); this.updateVolumeUI();
} }
} }
+5 -5
View File
@@ -1,10 +1,10 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="es">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - NodeCast TV</title> <title>Iniciar sesión — KiraTV</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg"> <link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="stylesheet" href="css/main.css"> <link rel="stylesheet" href="css/main.css">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
@@ -171,13 +171,13 @@
<path <path
d="M169.71,87.87c-4.77,0-9.1,1.88-12.31,4.92L35.3,22.3c.35-1.4.56-2.86.56-4.37C35.86,8.03,27.83,0,17.93,0S0,8.03,0,17.93c0,8.44,5.84,15.5,13.69,17.4v140c-7.71,2.01-13.41,8.99-13.41,17.33,0,9.9,8.03,17.93,17.93,17.93s17.93-8.03,17.93-17.93c0-1.56-.22-3.07-.6-4.51l121.21-69.98c3.26,3.42,7.86,5.55,12.95,5.55,9.9,0,17.93-8.03,17.93-17.93s-8.03-17.93-17.93-17.93ZM152.78,99.92c-.14.41-.27.83-.38,1.25h-51.47l-44.18-24.72L30.4,30.8c.33-.32.64-.65.94-.99l121.45,70.12ZM22.17,175.2V35.33c.23-.05.45-.11.67-.17l26.35,45.65-.34,50.04-25.74,44.59c-.31-.09-.63-.17-.95-.24ZM31.52,180.69c-.27-.3-.55-.6-.84-.89l25.48-44.13,44.64-25.78h51.47c.07.32.15.63.24.94L31.52,180.69Z" /> d="M169.71,87.87c-4.77,0-9.1,1.88-12.31,4.92L35.3,22.3c.35-1.4.56-2.86.56-4.37C35.86,8.03,27.83,0,17.93,0S0,8.03,0,17.93c0,8.44,5.84,15.5,13.69,17.4v140c-7.71,2.01-13.41,8.99-13.41,17.33,0,9.9,8.03,17.93,17.93,17.93s17.93-8.03,17.93-17.93c0-1.56-.22-3.07-.6-4.51l121.21-69.98c3.26,3.42,7.86,5.55,12.95,5.55,9.9,0,17.93-8.03,17.93-17.93s-8.03-17.93-17.93-17.93ZM152.78,99.92c-.14.41-.27.83-.38,1.25h-51.47l-44.18-24.72L30.4,30.8c.33-.32.64-.65.94-.99l121.45,70.12ZM22.17,175.2V35.33c.23-.05.45-.11.67-.17l26.35,45.65-.34,50.04-25.74,44.59c-.31-.09-.63-.17-.95-.24ZM31.52,180.69c-.27-.3-.55-.6-.84-.89l25.48-44.13,44.64-25.78h51.47c.07.32.15.63.24.94L31.52,180.69Z" />
</svg> </svg>
<h1>nodecast-tv</h1> <h1>KiraTV</h1>
<p id="login-subtitle">Sign in to continue</p> <p id="login-subtitle">Inicia sesión para continuar</p>
</div> </div>
<div class="error-message" id="error-message"></div> <div class="error-message" id="error-message"></div>
<div class="setup-message" id="setup-message"> <div class="setup-message" id="setup-message">
Welcome! Please create your admin account to get started. Bienvenido. Crea tu cuenta de administrador para empezar.
</div> </div>
<form id="login-form"> <form id="login-form">
+2 -1
View File
@@ -67,7 +67,8 @@ function getDefaultSettings() {
forceVideoTranscode: false, // Force Video Transcode forceVideoTranscode: false, // Force Video Transcode
forceRemux: false, forceRemux: false,
autoTranscode: true, 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', epgRefreshInterval: '24',
// User-Agent settings // User-Agent settings
userAgentPreset: 'chrome', // chrome | vlc | tivimate | custom userAgentPreset: 'chrome', // chrome | vlc | tivimate | custom
+9 -2
View File
@@ -27,7 +27,14 @@ app.use(session({
app.use(passport.initialize()); app.use(passport.initialize());
app.use(passport.session()); 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) // FFMPEG Configuration (optional - for transcoding support)
// Priority: 1. System FFmpeg (better Docker DNS support), 2. ffmpeg-static npm package // Priority: 1. System FFmpeg (better Docker DNS support), 2. ffmpeg-static npm package
@@ -199,7 +206,7 @@ app.use((err, req, res, next) => {
}); });
app.listen(PORT, async () => { 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 // Load plugins
await loadPlugins().catch(err => { await loadPlugins().catch(err => {
+66 -24
View File
@@ -1,6 +1,14 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const { getDb } = require('../db/sqlite'); 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 // Helper to map API item types to DB types and tables
function mapItemType(apiType) { function mapItemType(apiType) {
@@ -21,27 +29,44 @@ router.get('/hidden', async (req, res) => {
const { sourceId } = req.query; const { sourceId } = req.query;
const db = getDb(); 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) => ({ const resultFormat = (row, itemType) => ({
source_id: row.source_id, source_id: row.source_id,
item_type: itemType, item_type: itemType,
item_id: itemType.includes('category') || itemType === 'group' ? row.category_id : row.item_id 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 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 itemQuery = `SELECT source_id, item_id, type FROM playlist_items WHERE is_hidden = 1`;
let catParams = [];
let itemParams = [];
const params = [];
if (sourceId) { if (sourceId) {
if (!(await assertCanUseSourceById(req, res, sources, sourceId))) return;
const sid = parseInt(sourceId, 10);
catQuery += ` AND source_id = ?`; catQuery += ` AND source_id = ?`;
itemQuery += ` AND source_id = ?`; itemQuery += ` AND source_id = ?`;
const sid = parseInt(sourceId); catParams.push(sid);
params.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 hiddenCats = db.prepare(catQuery).all(...catParams);
const hiddenItems = db.prepare(itemQuery).all(...params); const hiddenItems = db.prepare(itemQuery).all(...itemParams);
const hidden = [];
hiddenCats.forEach(row => { hiddenCats.forEach(row => {
let apiType; let apiType;
@@ -75,6 +100,7 @@ router.post('/hide', async (req, res) => {
const mapping = mapItemType(itemType); const mapping = mapItemType(itemType);
if (!mapping) return res.status(400).json({ error: 'Invalid item type' }); if (!mapping) return res.status(400).json({ error: 'Invalid item type' });
if (!(await assertCanUseSourceById(req, res, sources, sourceId))) return;
const db = getDb(); const db = getDb();
const idCol = mapping.table === 'categories' ? 'category_id' : 'item_id'; const idCol = mapping.table === 'categories' ? 'category_id' : 'item_id';
@@ -101,6 +127,7 @@ router.post('/show', async (req, res) => {
const mapping = mapItemType(itemType); const mapping = mapItemType(itemType);
if (!mapping) return res.status(400).json({ error: 'Invalid item type' }); if (!mapping) return res.status(400).json({ error: 'Invalid item type' });
if (!(await assertCanUseSourceById(req, res, sources, sourceId))) return;
const db = getDb(); const db = getDb();
const idCol = mapping.table === 'categories' ? 'category_id' : 'item_id'; const idCol = mapping.table === 'categories' ? 'category_id' : 'item_id';
@@ -127,6 +154,8 @@ router.get('/hidden/check', async (req, res) => {
const mapping = mapItemType(itemType); const mapping = mapItemType(itemType);
if (!mapping) return res.json({ hidden: false }); if (!mapping) return res.json({ hidden: false });
if (!(await assertCanUseSourceById(req, res, sources, sourceId))) return;
const db = getDb(); const db = getDb();
const idCol = mapping.table === 'categories' ? 'category_id' : 'item_id'; const idCol = mapping.table === 'categories' ? 'category_id' : 'item_id';
@@ -148,13 +177,16 @@ router.post('/hide/bulk', async (req, res) => {
const { items } = req.body; const { items } = req.body;
if (!Array.isArray(items)) return res.status(400).json({ error: 'items array required' }); 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(); 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 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 = ?'); 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 hideCatChildren = db.prepare('UPDATE playlist_items SET is_hidden = 1 WHERE source_id = ? AND type = ? AND category_id = ?');
const runBulk = db.transaction((list) => { const runBulk = db.transaction((list) => {
@@ -162,12 +194,9 @@ router.post('/hide/bulk', async (req, res) => {
const mapping = mapItemType(item.itemType); const mapping = mapItemType(item.itemType);
if (mapping) { if (mapping) {
if (mapping.table === 'categories') { if (mapping.table === 'categories') {
// Hide the category
hideCat.run(item.sourceId, mapping.type, item.itemId); hideCat.run(item.sourceId, mapping.type, item.itemId);
// Cascade to children
hideCatChildren.run(item.sourceId, mapping.type, item.itemId); hideCatChildren.run(item.sourceId, mapping.type, item.itemId);
} else { } else {
// Hide individual item
hideItem.run(item.sourceId, mapping.type, item.itemId); hideItem.run(item.sourceId, mapping.type, item.itemId);
} }
} }
@@ -191,13 +220,16 @@ router.post('/show/bulk', async (req, res) => {
const { items } = req.body; const { items } = req.body;
if (!Array.isArray(items)) return res.status(400).json({ error: 'items array required' }); 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(); 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 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 = ?'); 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 showCatChildren = db.prepare('UPDATE playlist_items SET is_hidden = 0 WHERE source_id = ? AND type = ? AND category_id = ?');
const runBulk = db.transaction((list) => { const runBulk = db.transaction((list) => {
@@ -205,12 +237,9 @@ router.post('/show/bulk', async (req, res) => {
const mapping = mapItemType(item.itemType); const mapping = mapItemType(item.itemType);
if (mapping) { if (mapping) {
if (mapping.table === 'categories') { if (mapping.table === 'categories') {
// Show the category
showCat.run(item.sourceId, mapping.type, item.itemId); showCat.run(item.sourceId, mapping.type, item.itemId);
// Cascade to children
showCatChildren.run(item.sourceId, mapping.type, item.itemId); showCatChildren.run(item.sourceId, mapping.type, item.itemId);
} else { } else {
// Show individual item
showItem.run(item.sourceId, mapping.type, item.itemId); showItem.run(item.sourceId, mapping.type, item.itemId);
} }
} }
@@ -234,14 +263,15 @@ router.post('/show/all', async (req, res) => {
const { sourceId, contentType } = req.body; const { sourceId, contentType } = req.body;
if (!sourceId) return res.status(400).json({ error: 'sourceId required' }); if (!sourceId) return res.status(400).json({ error: 'sourceId required' });
if (!(await assertCanUseSourceById(req, res, sources, sourceId))) return;
const db = getDb(); const db = getDb();
let catCount = 0; let catCount = 0;
let itemCount = 0; let itemCount = 0;
// Determine which types to update based on contentType
const types = contentType === 'movies' ? ['movie'] const types = contentType === 'movies' ? ['movie']
: contentType === 'series' ? ['series'] : contentType === 'series' ? ['series']
: ['live']; // default to channels : ['live'];
for (const type of types) { for (const type of types) {
const catResult = db.prepare(`UPDATE categories SET is_hidden = 0 WHERE source_id = ? AND type = ?`).run(sourceId, type); 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; const { sourceId, contentType } = req.body;
if (!sourceId) return res.status(400).json({ error: 'sourceId required' }); if (!sourceId) return res.status(400).json({ error: 'sourceId required' });
if (!(await assertCanUseSourceById(req, res, sources, sourceId))) return;
const db = getDb(); const db = getDb();
let catCount = 0; let catCount = 0;
let itemCount = 0; let itemCount = 0;
// Determine which types to update based on contentType
const types = contentType === 'movies' ? ['movie'] const types = contentType === 'movies' ? ['movie']
: contentType === 'series' ? ['series'] : contentType === 'series' ? ['series']
: ['live']; // default to channels : ['live'];
for (const type of types) { for (const type of types) {
const catResult = db.prepare(`UPDATE categories SET is_hidden = 1 WHERE source_id = ? AND type = ?`).run(sourceId, type); 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' }); 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 db = getDb();
const recentItems = db.prepare(` const recentItems = db.prepare(`
SELECT * FROM playlist_items p SELECT * FROM playlist_items p
@@ -308,11 +351,11 @@ router.get('/recent', async (req, res) => {
AND c.type = p.type AND c.type = p.type
AND c.is_hidden = 1 AND c.is_hidden = 1
) )
${accessibleFilter}
ORDER BY p.added_at DESC ORDER BY p.added_at DESC
LIMIT ? LIMIT ?
`).all(type, parseInt(limit)); `).all(...params);
// Parse JSON data for each item
const formatted = recentItems.map(item => ({ const formatted = recentItems.map(item => ({
...item, ...item,
data: JSON.parse(item.data) data: JSON.parse(item.data)
@@ -326,4 +369,3 @@ router.get('/recent', async (req, res) => {
}); });
module.exports = router; module.exports = router;
+30 -7
View File
@@ -170,22 +170,46 @@ router.get('/', async (req, res) => {
console.log(`[Probe] Probing: ${url.substring(0, 80)}... ${ua ? `(UA: ${ua})` : ''}`); console.log(`[Probe] Probing: ${url.substring(0, 80)}... ${ua ? `(UA: ${ua})` : ''}`);
// Retry probe up to 3 times on 5XX errors (provider slot not released yet)
const MAX_PROBE_RETRIES = 3;
const PROBE_RETRY_DELAY_MS = 2500;
let lastErr = null;
for (let attempt = 1; attempt <= MAX_PROBE_RETRIES; attempt++) {
try { try {
const probeResult = await probeStream(url, ffprobePath, ua); const probeResult = await probeStream(url, ffprobePath, ua);
const analysis = analyzeProbeResult(probeResult, url); const analysis = analyzeProbeResult(probeResult, url);
// Cache result // Cache successful result
probeCache.set(cacheKey, { result: analysis, timestamp: Date.now() }); probeCache.set(cacheKey, { result: analysis, timestamp: Date.now() });
console.log(`[Probe] Result: video=${analysis.video}, audio=${analysis.audio}, ` + console.log(`[Probe] Result (attempt ${attempt}): video=${analysis.video}, audio=${analysis.audio}, ` +
`container=${analysis.container}, compatible=${analysis.compatible}, ` + `container=${analysis.container}, compatible=${analysis.compatible}, ` +
`needsRemux=${analysis.needsRemux}, needsTranscode=${analysis.needsTranscode}`); `needsRemux=${analysis.needsRemux}, needsTranscode=${analysis.needsTranscode}`);
res.json(analysis); return res.json(analysis);
} catch (err) { } catch (err) {
console.error('[Probe] Failed:', err.message); 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;
}
}
// On error, assume transcode needed to be safe 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({ res.json({
video: 'unknown', video: 'unknown',
audio: 'unknown', audio: 'unknown',
@@ -193,9 +217,8 @@ router.get('/', async (req, res) => {
compatible: false, compatible: false,
needsRemux: false, needsRemux: false,
needsTranscode: true, needsTranscode: true,
error: err.message error: lastErr.message
}); });
}
}); });
module.exports = router; module.exports = router;
+45 -18
View File
@@ -12,6 +12,35 @@ const https = require('https');
const { spawn } = require('child_process'); const { spawn } = require('child_process');
const ffmpegPath = require('ffmpeg-static'); const ffmpegPath = require('ffmpeg-static');
const { Readable } = require('stream'); 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 // Default cache max age in hours
const DEFAULT_MAX_AGE_HOURS = 24; const DEFAULT_MAX_AGE_HOURS = 24;
@@ -83,8 +112,8 @@ function getStreamsFromDb(sourceId, type, categoryId = null, includeHidden = fal
// Login / Authenticate // Login / Authenticate
router.get('/xtream/:sourceId', async (req, res) => { router.get('/xtream/:sourceId', async (req, res) => {
try { try {
const source = await sources.getById(req.params.sourceId); const source = req.grantedSource;
if (!source || source.type !== 'xtream') return res.status(404).send('Source not found'); if (source.type !== 'xtream') return res.status(404).send('Source not found');
// Proxy auth check to upstream to ensure credentials are still valid // 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 // Proxy series info request
router.get('/xtream/:sourceId/series_info', async (req, res) => { router.get('/xtream/:sourceId/series_info', async (req, res) => {
try { try {
const source = await sources.getById(req.params.sourceId); const source = req.grantedSource;
if (!source) return res.status(404).send('Source not found');
const seriesId = req.query.series_id; const seriesId = req.query.series_id;
if (!seriesId) return res.status(400).send('series_id required'); 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 // VOD Info
router.get('/xtream/:sourceId/vod_info', async (req, res) => { router.get('/xtream/:sourceId/vod_info', async (req, res) => {
try { try {
const source = await sources.getById(req.params.sourceId); const source = req.grantedSource;
if (!source) return res.status(404).send('Source not found');
const vodId = req.query.vod_id; const vodId = req.query.vod_id;
if (!vodId) return res.status(400).send('vod_id required'); 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 // Returns the direct stream URL for a given stream ID
router.get('/xtream/:sourceId/stream/:streamId/:type', async (req, res) => { router.get('/xtream/:sourceId/stream/:streamId/:type', async (req, res) => {
try { try {
const source = await sources.getById(req.params.sourceId); const source = req.grantedSource;
if (!source || source.type !== 'xtream') { if (source.type !== 'xtream') {
return res.status(404).json({ error: 'Xtream source not found' }); return res.status(404).json({ error: 'Xtream source not found' });
} }
const streamId = req.params.streamId; const streamId = req.params.streamId;
const type = req.params.type || 'live'; const type = req.params.type || 'live';
const container = req.query.container || 'm3u8'; const container = req.query.container || 'ts';
// Construct the Xtream stream URL // Construct the Xtream stream URL
// Format: http://server:port/live/username/password/streamId.container (for live) // 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) => { router.get('/xtream/:sourceId/:action', async (req, res) => {
try { try {
const sourceId = req.params.sourceId; const sourceId = req.params.sourceId;
const source = await sources.getById(sourceId); const source = req.grantedSource;
if (!source || source.type !== 'xtream') { if (source.type !== 'xtream') {
return res.status(404).json({ error: 'Xtream source not found' }); 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) => { router.get('/xtream/:sourceId/stream/:streamId/:type?', async (req, res) => {
try { try {
const source = await sources.getById(req.params.sourceId); const source = req.grantedSource;
if (!source || source.type !== 'xtream') { if (source.type !== 'xtream') {
return res.status(404).json({ error: 'Xtream source not found' }); return res.status(404).json({ error: 'Xtream source not found' });
} }
const api = xtreamApi.createFromSource(source); const api = xtreamApi.createFromSource(source);
const { streamId, type = 'live' } = req.params; const { streamId, type = 'live' } = req.params;
const { container = 'm3u8' } = req.query; const { container = 'ts' } = req.query;
const url = api.buildStreamUrl(streamId, type, container); const url = api.buildStreamUrl(streamId, type, container);
res.json({ url }); res.json({ url });
@@ -505,8 +532,8 @@ router.get('/xtream/:sourceId/stream/:streamId/:type?', async (req, res) => {
router.get('/epg/:sourceId', async (req, res) => { router.get('/epg/:sourceId', async (req, res) => {
try { try {
const sourceId = req.params.sourceId; const sourceId = req.params.sourceId;
const source = await sources.getById(sourceId); const source = req.grantedSource;
if (!source || (source.type !== 'epg' && source.type !== 'xtream')) { if (source.type !== 'epg' && source.type !== 'xtream') {
return res.status(404).json({ error: 'Valid EPG source not found' }); 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) => { router.post('/epg/:sourceId/channels', async (req, res) => {
try { try {
const source = await sources.getById(req.params.sourceId); const source = req.grantedSource;
if (!source || source.type !== 'epg') { if (source.type !== 'epg') {
return res.status(404).json({ error: 'EPG source not found' }); 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 xtreamApi = require('../services/xtreamApi');
const syncService = require('../services/syncService'); const syncService = require('../services/syncService');
const m3uParser = require('../services/m3uParser'); const m3uParser = require('../services/m3uParser');
const { requireAuth, requireAdmin } = require('../auth');
const { canUserAccessSource, getAccessibleSourceIds } = require('../sourceAccess');
// Get all sources router.use(requireAuth);
router.get('/', async (req, res) => {
try { function sanitizeSourceList(list) {
const allSources = await sources.getAll(); return list.map((s) => ({
// Don't expose passwords in list view
const sanitized = allSources.map(s => ({
...s, ...s,
password: s.password ? '••••••••' : null password: s.password ? '••••••••' : null
})); }));
res.json(sanitized); }
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();
const filtered = allSources.filter((s) => canUserAccessSource(req.user, s));
res.json(sanitizeSourceList(filtered));
} catch (err) { } catch (err) {
console.error('Error getting sources:', err); console.error('Error getting sources:', err);
res.status(500).json({ error: 'Failed to get sources' }); 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) => { router.get('/status', async (req, res) => {
try { try {
const { getDb } = require('../db/sqlite');
const db = getDb(); 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); res.json(statuses);
} catch (err) { } catch (err) {
console.error('Error getting sync status:', 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) => { router.get('/type/:type', async (req, res) => {
try { try {
const typeSources = await sources.getByType(req.params.type); 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) { } catch (err) {
console.error('Error getting sources by type:', err); console.error('Error getting sources by type:', err);
res.status(500).json({ error: 'Failed to get sources' }); 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 // Get single source
router.get('/:id', async (req, res) => { router.get('/:id', async (req, res) => {
try { try {
@@ -53,6 +137,9 @@ router.get('/:id', async (req, res) => {
if (!source) { if (!source) {
return res.status(404).json({ error: 'Source not found' }); 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); res.json(source);
} catch (err) { } catch (err) {
console.error('Error getting source:', 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' }); return res.status(400).json({ error: 'Invalid source type' });
} }
const source = await sources.create({ type, name, url, username, password }); let ownerId = null;
// Trigger Sync 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); syncService.syncSource(source.id).catch(console.error);
res.status(201).json(source); res.status(201).json(source);
} catch (err) { } catch (err) {
@@ -90,16 +183,23 @@ router.put('/:id', async (req, res) => {
if (!existing) { if (!existing) {
return res.status(404).json({ error: 'Source not found' }); 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 { name, url, username, password } = req.body;
const updated = await sources.update(req.params.id, { const updates = {
name: name || existing.name, name: name || existing.name,
url: url || existing.url, url: url || existing.url,
username: username !== undefined ? username : existing.username, username: username !== undefined ? username : existing.username,
password: password !== undefined ? password : existing.password password: password !== undefined ? password : existing.password
}); };
// Trigger Sync (if critical fields changed? safely just trigger it) if (req.user.role === 'admin' && req.body.ownerId !== undefined) {
syncService.syncSource(parseInt(req.params.id)).catch(console.error); 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); res.json(updated);
} catch (err) { } catch (err) {
console.error('Error updating source:', err); console.error('Error updating source:', err);
@@ -110,13 +210,15 @@ router.put('/:id', async (req, res) => {
// Delete source // Delete source
router.delete('/:id', async (req, res) => { router.delete('/:id', async (req, res) => {
try { try {
const sourceId = parseInt(req.params.id); const sourceId = parseInt(req.params.id, 10);
const existing = await sources.getById(sourceId); const existing = await sources.getById(sourceId);
if (!existing) { if (!existing) {
return res.status(404).json({ error: 'Source not found' }); 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 db = getDb();
const deleteCategories = db.prepare('DELETE FROM categories WHERE source_id = ?'); const deleteCategories = db.prepare('DELETE FROM categories WHERE source_id = ?');
const deleteItems = db.prepare('DELETE FROM playlist_items 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`); 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); await sources.delete(sourceId);
res.json({ success: true }); res.json({ success: true });
@@ -143,14 +244,21 @@ router.delete('/:id', async (req, res) => {
// Toggle source enabled/disabled // Toggle source enabled/disabled
router.post('/:id/toggle', async (req, res) => { router.post('/:id/toggle', async (req, res) => {
try { 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); const updated = await sources.toggleEnabled(req.params.id);
if (!updated) { if (!updated) {
return res.status(404).json({ error: 'Source not found' }); return res.status(404).json({ error: 'Source not found' });
} }
// If enabled, trigger sync
if (updated.enabled) { 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); res.json(updated);
@@ -163,11 +271,13 @@ router.post('/:id/toggle', async (req, res) => {
// Manual Sync // Manual Sync
router.post('/:id/sync', async (req, res) => { router.post('/:id/sync', async (req, res) => {
try { try {
const id = parseInt(req.params.id); const id = parseInt(req.params.id, 10);
const source = await sources.getById(id); const source = await sources.getById(id);
if (!source) return res.status(404).json({ error: 'Source not found' }); 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); syncService.syncSource(id).catch(console.error);
res.json({ success: true, message: 'Sync started' }); res.json({ success: true, message: 'Sync started' });
@@ -184,6 +294,9 @@ router.post('/:id/test', async (req, res) => {
if (!source) { if (!source) {
return res.status(404).json({ error: 'Source not found' }); 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') { if (source.type === 'xtream') {
const result = await xtreamApi.authenticate(source.url, source.username, source.password); 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; module.exports = router;
+126 -18
View File
@@ -1,10 +1,45 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const { spawn } = require('child_process'); const { spawn, execFile } = require('child_process');
const path = require('path'); const path = require('path');
const fs = require('fs').promises; const fs = require('fs').promises;
const db = require('../db'); const db = require('../db');
const transcodeSession = require('../services/transcodeSession'); const transcodeSession = require('../services/transcodeSession');
const streamInput = require('../utils/streamInput');
/**
* Probe a stream URL with FFprobe to get its total duration in seconds.
* VOD pesado usa probes mayores y timeout más alto (MKV Jellyfin/remux).
*/
function probeDuration(url, ffprobePath, userAgent) {
return new Promise((resolve) => {
const sizing = streamInput.probeSizingForUrl(url);
const referer = streamInput.deriveRefererFromUrl(url);
const args = [
'-v', 'quiet',
'-print_format', 'json',
'-show_entries', 'format=duration',
'-user_agent', userAgent || 'Mozilla/5.0',
'-probesize', sizing.probesize,
'-analyzeduration', sizing.analyzeduration,
];
if (referer) {
args.push('-referer', referer);
}
args.push(url);
const timeoutMs = sizing.heavy ? 45000 : 8000;
const proc = execFile(ffprobePath || 'ffprobe', args, { timeout: timeoutMs }, (err, stdout) => {
if (err) { resolve(0); return; }
try {
const data = JSON.parse(stdout);
const dur = parseFloat(data?.format?.duration);
resolve(Number.isFinite(dur) && dur > 0 ? Math.round(dur) : 0);
} catch { resolve(0); }
});
// Ensure the process is killed if execFile timeout fires
proc.on('error', () => resolve(0));
});
}
/** /**
* Transcode Routes * Transcode Routes
@@ -40,38 +75,78 @@ router.post('/session', async (req, res) => {
const userAgent = db.getUserAgent(settings); const userAgent = db.getUserAgent(settings);
try { try {
const session = await transcodeSession.createSession(url, { const ffprobePath = (ffmpegPath || 'ffmpeg').replace(/ffmpeg$/, 'ffprobe');
// Probe duration and start session in parallel (don't block on duration)
const [session, durationSec] = await Promise.all([
transcodeSession.createSession(url, {
ffmpegPath, ffmpegPath,
userAgent, userAgent,
seekOffset: seekOffset || 0, seekOffset: seekOffset || 0,
hwEncoder: settings.hwEncoder || 'software', hwEncoder: settings.hwEncoder || 'software',
maxResolution: settings.maxResolution || '1080p', maxResolution: settings.maxResolution || '1080p',
quality: settings.quality || 'medium', quality: settings.quality || 'medium',
audioMixPreset: settings.audioMixPreset || 'auto', // Audio downmix preset audioMixPreset: settings.audioMixPreset || 'auto',
// Upscaling options
upscaleEnabled: settings.upscaleEnabled || false, upscaleEnabled: settings.upscaleEnabled || false,
upscaleMethod: settings.upscaleMethod || 'hardware', upscaleMethod: settings.upscaleMethod || 'hardware',
upscaleTarget: settings.upscaleTarget || '1080p', upscaleTarget: settings.upscaleTarget || '1080p',
videoMode: videoMode, // 'copy' or 'encode' videoMode: videoMode,
videoCodec: videoCodec, // 'h264', 'hevc', etc. videoCodec: videoCodec,
audioCodec: audioCodec, // 'aac', 'ac3', etc. audioCodec: audioCodec,
audioChannels: audioChannels // number of channels (2=stereo) audioChannels: audioChannels
}); }).then(async s => { await s.start(); return s; }),
probeDuration(url, ffprobePath, userAgent)
await session.start(); ]);
// Wait for playlist to be ready (first segments generated) // Wait for playlist to be ready (first segments generated)
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) { if (!ready) {
await transcodeSession.removeSession(session.id); await transcodeSession.removeSession(session.id);
return res.status(500).json({ error: 'Transcoding failed to start', reason: 'Playlist not generated in time' }); return res.status(500).json({ error: 'Transcoding failed to start', reason: 'Playlist not generated in time' });
} }
console.log(`[Transcode] Session ${session.id} ready. Source duration: ${durationSec}s`);
res.json({ res.json({
sessionId: session.id, sessionId: session.id,
playlistUrl: `/api/transcode/${session.id}/stream.m3u8`, playlistUrl: `/api/transcode/${session.id}/stream.m3u8`,
status: session.status status: session.status,
durationSec: durationSec || 0
}); });
} catch (err) { } catch (err) {
@@ -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) * List all active sessions (for debugging)
* GET /api/transcode/sessions * 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 User-Agent: ${settings.userAgentPreset}`);
console.log(`[Transcode] Using binary: ${ffmpegPath}`); console.log(`[Transcode] Using binary: ${ffmpegPath}`);
const sizing = streamInput.probeSizingForUrl(url);
const referer = streamInput.deriveRefererFromUrl(url);
// FFmpeg arguments for transcoding // FFmpeg arguments for transcoding
// Optimized for VOD content with incompatible audio (Dolby/AC3/EAC3) // Optimized for VOD content with incompatible audio (Dolby/AC3/EAC3)
// Also works for live streams with ad stitching (Pluto TV, etc.) // Also works for live streams with ad stitching (Pluto TV, etc.)
@@ -182,9 +284,13 @@ router.get('/', async (req, res) => {
'-hide_banner', '-hide_banner',
'-loglevel', 'warning', '-loglevel', 'warning',
'-user_agent', userAgent, '-user_agent', userAgent,
// Faster startup - reduced probe/analyze for quicker first bytes ];
'-probesize', '2000000', // 2MB (reduced from 5MB) if (referer) {
'-analyzeduration', '3000000', // 3 seconds (reduced from 10s) args.push('-referer', referer);
}
args.push(
'-probesize', sizing.probesize,
'-analyzeduration', sizing.analyzeduration,
// Error resilience: generate timestamps, discard corrupt packets // Error resilience: generate timestamps, discard corrupt packets
'-fflags', '+genpts+discardcorrupt+nobuffer', '-fflags', '+genpts+discardcorrupt+nobuffer',
// Ignore errors in stream and continue // Ignore errors in stream and continue
@@ -197,7 +303,9 @@ router.get('/', async (req, res) => {
'-reconnect_delay_max', '3', '-reconnect_delay_max', '3',
// Prevent Range/HEAD requests that some providers reject with 405 // Prevent Range/HEAD requests that some providers reject with 405
'-seekable', '0', '-seekable', '0',
'-i', url, '-i', url
);
args.push(
// Map only first video and audio stream (avoid subtitle streams causing issues) // Map only first video and audio stream (avoid subtitle streams causing issues)
'-map', '0:v:0', '-map', '0:v:0',
'-map', '0:a:0?', // ? makes audio optional if not present '-map', '0:a:0?', // ? makes audio optional if not present
@@ -218,7 +326,7 @@ router.get('/', async (req, res) => {
'-movflags', 'frag_keyframe+empty_moov+default_base_moof+faststart', '-movflags', 'frag_keyframe+empty_moov+default_base_moof+faststart',
'-flush_packets', '1', // Send data immediately '-flush_packets', '1', // Send data immediately
'-' // Output to stdout '-' // Output to stdout
]; );
console.log(`[Transcode] Full command: ${ffmpegPath} ${args.join(' ')}`); console.log(`[Transcode] Full command: ${ffmpegPath} ${args.join(' ')}`);
+5 -3
View File
@@ -228,16 +228,18 @@ async function detect() {
detectAMF() 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'; let recommended = 'software';
if (nvidia.available) { if (nvidia.available) {
recommended = 'nvenc'; recommended = 'nvenc';
} else if (amf.available) { } else if (amf.available) {
recommended = 'amf'; recommended = 'amf';
} else if (qsv.available) {
recommended = 'qsv';
} else if (vaapi.available) { } else if (vaapi.available) {
recommended = 'vaapi'; recommended = 'vaapi';
} else if (qsv.available) {
recommended = 'qsv';
} }
hwCapabilities = { 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 crypto = require('crypto');
const EventEmitter = require('events'); const EventEmitter = require('events');
const hwDetect = require('./hwDetect'); const hwDetect = require('./hwDetect');
const streamInput = require('../utils/streamInput');
// Session storage // Session storage
const sessions = new Map(); const sessions = new Map();
@@ -26,9 +27,9 @@ const sessions = new Map();
const CACHE_DIR = path.join(process.cwd(), 'transcode-cache'); const CACHE_DIR = path.join(process.cwd(), 'transcode-cache');
// Session settings // Session settings
const SESSION_TIMEOUT_MS = 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 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 * Generate a unique session ID
@@ -181,11 +182,17 @@ class TranscodeSession extends EventEmitter {
console.log(`[TranscodeSession ${this.id}] Auto encoder resolved to: ${encoder}`); console.log(`[TranscodeSession ${this.id}] Auto encoder resolved to: ${encoder}`);
} }
const sizing = streamInput.probeSizingForUrl(this.url);
const referer = streamInput.deriveRefererFromUrl(this.url);
const args = [ const args = [
'-hide_banner', '-hide_banner',
'-loglevel', 'warning', '-loglevel', 'warning',
'-user_agent', this.options.userAgent, '-user_agent', this.options.userAgent,
]; ];
if (referer) {
args.push('-referer', referer);
}
// Add hardware acceleration input options based on encoder (only if encoding) // Add hardware acceleration input options based on encoder (only if encoding)
if (videoMode === 'encode') { if (videoMode === 'encode') {
@@ -194,8 +201,8 @@ class TranscodeSession extends EventEmitter {
// Input options (common) // Input options (common)
args.push( args.push(
'-probesize', '5000000', '-probesize', sizing.probesize,
'-analyzeduration', '5000000', '-analyzeduration', sizing.analyzeduration,
'-fflags', '+genpts+discardcorrupt', '-fflags', '+genpts+discardcorrupt',
'-err_detect', 'ignore_err', '-err_detect', 'ignore_err',
'-reconnect', '1', '-reconnect', '1',
@@ -222,7 +229,10 @@ class TranscodeSession extends EventEmitter {
if (this.options.videoCodec === 'hevc' || this.options.videoCodec === 'h265') { if (this.options.videoCodec === 'hevc' || this.options.videoCodec === 'h265') {
args.push('-bsf:v', 'hevc_mp4toannexb'); args.push('-bsf:v', 'hevc_mp4toannexb');
} else if (this.options.videoCodec === 'h264' || this.options.videoCodec === 'avc') { } else if (this.options.videoCodec === 'h264' || this.options.videoCodec === 'avc') {
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 { } else {
// Fallback (e.g. unknown codec), try strict extraction // Fallback (e.g. unknown codec), try strict extraction
args.push('-bsf:v', 'dump_extra'); args.push('-bsf:v', 'dump_extra');
@@ -296,12 +306,12 @@ class TranscodeSession extends EventEmitter {
); );
break; break;
case 'vaapi': case 'vaapi':
// VAAPI hardware decoding (Linux) // Solo registramos el dispositivo VAAPI globalmente.
args.push( // Se usa decodificación SOFTWARE para máxima compatibilidad con
'-hwaccel', 'vaapi', // cualquier codec de entrada (HEVC, VP9, etc.) — el GPU Intel
'-hwaccel_device', '/dev/dri/renderD128', // puede no tener soporte de decodificación para todos ellos.
'-hwaccel_output_format', 'vaapi' // El upload a memoria VAAPI se hace en el filtro de vídeo.
); args.push('-vaapi_device', '/dev/dri/renderD128');
break; break;
case 'qsv': case 'qsv':
// Intel QuickSync hardware decoding // 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") // Force nv12 (8-bit) output to handle 10-bit inputs (fixes "10 bit encode not supported")
return `scale_cuda=-2:${height}:interp_algo=lanczos:format=nv12`; return `scale_cuda=-2:${height}:interp_algo=lanczos:format=nv12`;
case 'vaapi': case 'vaapi':
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': case 'qsv':
return `scale_qsv=w=-2:h=${height}:format=nv12`; return `scale_qsv=w=-2:h=${height}:format=nv12`;
case 'amf': case 'amf':
@@ -466,14 +478,11 @@ class TranscodeSession extends EventEmitter {
// The format is handled automatically when using -hwaccel_output_format vaapi // The format is handled automatically when using -hwaccel_output_format vaapi
args.push('-vf', this.buildScaleFilter('vaapi', height)); args.push('-vf', this.buildScaleFilter('vaapi', height));
// VAAPI encoder with quality setting // VAAPI encoder. No especificar -profile:v ni -pix_fmt (los gestiona el driver).
// Note: -global_quality is the portable way to set quality for VAAPI // -bf 3 puede causar problemas en algunos drivers Intel; se omite.
args.push( args.push(
'-c:v', 'h264_vaapi', '-c:v', 'h264_vaapi',
'-profile:v', 'main', // Use main profile for compatibility '-global_quality', String(qp)
'-global_quality', String(qp),
'-bf', '3',
'-pix_fmt', 'yuv420p' // Force 8-bit output for compatibility
); );
} }
@@ -551,6 +560,7 @@ class TranscodeSession extends EventEmitter {
/** /**
* Wait for playlist to be ready (with timeout) * Wait for playlist to be ready (with timeout)
* Fast-fails if FFmpeg exited with an error before the timeout.
*/ */
async waitForPlaylist(timeoutMs = 10000) { async waitForPlaylist(timeoutMs = 10000) {
const startTime = Date.now(); const startTime = Date.now();
@@ -558,6 +568,11 @@ class TranscodeSession extends EventEmitter {
if (await this.isPlaylistReady()) { if (await this.isPlaylistReady()) {
return true; return true;
} }
// Fast-fail: FFmpeg already exited with an error — no point waiting
if (this.status === 'error' && !this.process) {
console.log(`[TranscodeSession ${this.id}] Fast-fail: FFmpeg exited with error, aborting wait`);
return false;
}
await new Promise(resolve => setTimeout(resolve, 200)); await new Promise(resolve => setTimeout(resolve, 200));
} }
return false; return false;
+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
};