Compare commits
229 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bb189a472e | |||
| 0e26a90dae | |||
| f3f9d79e8a | |||
| 50d337f87d | |||
| de41aa04fd | |||
| 72e14dca57 | |||
| 6eafa871b5 | |||
| 31aafe4d37 | |||
| 146f7dd1b1 | |||
| bfc80738f4 | |||
| 80d2c4bfb3 | |||
| 4e116d864b | |||
| 7006513aaf | |||
| c8ca09011f | |||
| c042ff37b1 | |||
| ac6c82e3fc | |||
| e83b173755 | |||
| 89dbb96c92 | |||
| ea4a0a4577 | |||
| 6f966f1095 | |||
| 47d582cb8c | |||
| 13badd249e | |||
| cddf02b114 | |||
| 3be14ef2fa | |||
| 3272b19177 | |||
| 8d5e94ccca | |||
| 7d5dbd94d6 | |||
| 056555c7b4 | |||
| 059cfecdcc | |||
| 237d11cf00 | |||
| 877c6f8df9 | |||
| 358854ad7a | |||
| 4b5d6c1b78 | |||
| dd7d6bbf1c | |||
| 4061bfa2a0 | |||
| 995fe12396 | |||
| e08bcfcea7 | |||
| c7fb245d6b | |||
| 100a26fe4e | |||
| 475c50b743 | |||
| 53c577a932 | |||
| 4a8be395ca | |||
| f9fefa3947 | |||
| b890d30253 | |||
| bf111e1fca | |||
| 052178e395 | |||
| d6eda66360 | |||
| dc029f75e5 | |||
| d54428472c | |||
| 51a61aaf5a | |||
| 5752f529cb | |||
| 82b93602c1 | |||
| 8137f49c4c | |||
| 0293e9d44b | |||
| fb2a50543a | |||
| c4cc8ef63c | |||
| 8304e7abe1 | |||
| 95591a0c84 | |||
| 26ccafa3ec | |||
| 453f93599b | |||
| d85ed753ab | |||
| 81d5f6e61e | |||
| 0c797dad7a | |||
| 39fcb36e53 | |||
| c1f8077438 | |||
| c01c33eed3 | |||
| 9919975663 | |||
| de7d28d3bc | |||
| 2c34359b2c | |||
| efcf14a545 | |||
| 77eb7bff5f | |||
| f0c1393ce0 | |||
| 2a03cad213 | |||
| c1a1ac4faa | |||
| 7c9c80c2f1 | |||
| b467471590 | |||
| 196b9520b7 | |||
| 450795818e | |||
| aa0fb7b956 | |||
| 0d16182cdf | |||
| 12a24cef09 | |||
| 60010cc734 | |||
| 0a013ec05f | |||
| 616ec37a93 | |||
| 6834849cad | |||
| 17923d8996 | |||
| b81ce4856e | |||
| c951a5998e | |||
| f2fde1ea54 | |||
| 838b540922 | |||
| c589b00e57 | |||
| 04fcabda84 | |||
| a86f9ca878 | |||
| 098dc3a8ce | |||
| 5f24d7e264 | |||
| d96bb7c776 | |||
| aeee45869b | |||
| 8d36a2c07e | |||
| 346a0519e9 | |||
| 53a54c760c | |||
| e8e6ba27d7 | |||
| 67d9cb26d2 | |||
| ffd995c1c5 | |||
| 7184a1ba56 | |||
| 05996985f1 | |||
| 3e834d4f41 | |||
| 28f16980e8 | |||
| 955d68e899 | |||
| 5bb0629de2 | |||
| f525a6ee67 | |||
| 3e377da67c | |||
| 2d20678e05 | |||
| 6557b9061f | |||
| 7f9485b663 | |||
| b119b37b87 | |||
| 813cd0cbe6 | |||
| a00082b0ef | |||
| ca87a5f143 | |||
| 9256883aa6 | |||
| bd9106be49 | |||
| dd95c6f6ed | |||
| 8d37f22f53 | |||
| 374a87b2fd | |||
| 1193e07017 | |||
| 8805ddbc13 | |||
| ec814be3dd | |||
| d1871f4077 | |||
| fb176412f0 | |||
| b7bc03a960 | |||
| 55c9a796b4 | |||
| 1be84d3ade | |||
| c48f2e070e | |||
| 02ff6f3f80 | |||
| 024b847d05 | |||
| d5ebd270dc | |||
| 0a6ebccce2 | |||
| bed168c646 | |||
| 096420596b | |||
| e1a197f2be | |||
| fc2f20d921 | |||
| 9fb8df7255 | |||
| ec9a6bcc23 | |||
| 46c68813ec | |||
| 51c2fb915c | |||
| 218757c6c3 | |||
| 54cedd197b | |||
| 2531f1786f | |||
| ea3aff2f04 | |||
| 82a0743262 | |||
| ed18b8662c | |||
| 2ab9b34f73 | |||
| 79da89ca40 | |||
| d1bfd1b9e7 | |||
| 404364b904 | |||
| a90ac32a3b | |||
| bec1b74208 | |||
| 7592702137 | |||
| 4683582dd0 | |||
| 1f4e38b37e | |||
| 6d55a81d3d | |||
| e70232055f | |||
| 20e4101649 | |||
| f3dd545eb4 | |||
| 71d59947b5 | |||
| c037ef6862 | |||
| 6daa3071be | |||
| 264a184f52 | |||
| bfb984be6c | |||
| b5c8581318 | |||
| d63c9c37d7 | |||
| 3b0d311756 | |||
| 3fffdddf4b | |||
| 3b14a17bfb | |||
| f8d625e9fe | |||
| bca9aa8c0a | |||
| 0a94904335 | |||
| 0513016f6c | |||
| 9aabfc9b43 | |||
| 413f5e2507 | |||
| 59bc6d4a46 | |||
| dc89e8018f | |||
| d57d7a5fc9 | |||
| 83ca622efa | |||
| 32e0434785 | |||
| 61fe03f950 | |||
| e4ae0e9b69 | |||
| 54a4b84d6e | |||
| 4931c08acd | |||
| a216eb32bb | |||
| 9ef9c847fc | |||
| 29a7996ecd | |||
| 44365c4dad | |||
| e8bfb56c8b | |||
| b504faf909 | |||
| 3b460591e0 | |||
| 3de1971383 | |||
| 15098ad6ed | |||
| 2ebe52685f | |||
| dc15e1f396 | |||
| 7e5a4174d1 | |||
| 4e398bbe38 | |||
| 3f4fd49023 | |||
| 7260f988ca | |||
| 89769dccb0 | |||
| 5da26c8045 | |||
| 58ee98adc2 | |||
| 1f81323f16 | |||
| 064d550cc8 | |||
| d251488f5f | |||
| 57b0f7ba81 | |||
| ebb3491005 | |||
| a7e0db7a4f | |||
| 1a80b4c036 | |||
| ec05112f8f | |||
| c25da2e011 | |||
| 4ab2128f74 | |||
| 79d84aa64b | |||
| 6a1d5b44e9 | |||
| f76e41c398 | |||
| 762131f939 | |||
| 2028ecab08 | |||
| bb5dc89311 | |||
| 52a7cd53b4 | |||
| e73ed9d8b1 | |||
| fda2cc580e | |||
| 52bb407fcc | |||
| 275c713296 | |||
| 235b2099de | |||
| 4f9ec55874 |
+109
-42
@@ -1,5 +1,5 @@
|
||||
/* =====================================================
|
||||
* NodeCast TV - Dark Theme Design System
|
||||
* KiraTV — tema oscuro (azul marino + oro)
|
||||
===================================================== */
|
||||
|
||||
/* CSS Variables */
|
||||
@@ -7,32 +7,37 @@
|
||||
/* iOS Safari toolbar compensation (set dynamically by JS) */
|
||||
--ios-ui-bottom: 0px;
|
||||
|
||||
/* Brand — logo hero (marino #000066 / blanco; acento arena para botones) */
|
||||
--color-brand-navy: #000066;
|
||||
--color-brand-navy-mid: #050045;
|
||||
--color-accent: #e8d5b5;
|
||||
--color-accent-hover: #f5ead8;
|
||||
--color-accent-muted: rgba(232, 213, 181, 0.14);
|
||||
--color-on-accent: #000044;
|
||||
|
||||
/* Colors */
|
||||
--color-bg-primary: #0a0a0f;
|
||||
--color-bg-secondary: #12121a;
|
||||
--color-bg-tertiary: #1a1a25;
|
||||
--color-bg-hover: #22222f;
|
||||
--color-bg-active: #2a2a3a;
|
||||
--color-bg-primary: #000022;
|
||||
--color-bg-secondary: #050038;
|
||||
--color-bg-tertiary: #0a0a4d;
|
||||
--color-bg-hover: #12125c;
|
||||
--color-bg-active: #1a1a72;
|
||||
|
||||
--color-accent: #6366f1;
|
||||
--color-accent-hover: #818cf8;
|
||||
--color-accent-dim: rgba(99, 102, 241, 0.2);
|
||||
--color-accent-dim: var(--color-accent-muted);
|
||||
|
||||
--color-success: #10b981;
|
||||
--color-warning: #f59e0b;
|
||||
--color-error: #ef4444;
|
||||
--color-success: #34d399;
|
||||
--color-warning: #fbbf24;
|
||||
--color-error: #f87171;
|
||||
|
||||
--color-text-primary: #f1f1f5;
|
||||
--color-text-secondary: #a1a1aa;
|
||||
--color-text-muted: #71717a;
|
||||
--color-text-primary: #f4f6fb;
|
||||
--color-text-secondary: #a8b0c4;
|
||||
--color-text-muted: #6b7389;
|
||||
|
||||
--color-border: #27272a;
|
||||
--color-border-light: #3f3f46;
|
||||
--color-border: #243047;
|
||||
--color-border-light: #344062;
|
||||
|
||||
/* Glass effect */
|
||||
--glass-bg: rgba(18, 18, 26, 0.8);
|
||||
--glass-border: rgba(255, 255, 255, 0.1);
|
||||
--glass-bg: rgba(0, 0, 102, 0.88);
|
||||
--glass-border: rgba(255, 255, 255, 0.08);
|
||||
|
||||
/* Spacing */
|
||||
--space-xs: 4px;
|
||||
@@ -52,7 +57,7 @@
|
||||
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||
--shadow-glow: 0 0 20px rgba(99, 102, 241, 0.3);
|
||||
--shadow-glow: 0 4px 28px rgba(232, 213, 181, 0.16);
|
||||
|
||||
/* Transitions */
|
||||
--transition-fast: 150ms ease;
|
||||
@@ -83,11 +88,14 @@
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-family: 'DM Sans', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-bg-primary);
|
||||
background:
|
||||
radial-gradient(ellipse 120% 85% at 50% -30%, rgba(255, 255, 255, 0.04), transparent 52%),
|
||||
radial-gradient(ellipse 90% 55% at 100% 35%, rgba(0, 0, 102, 0.45), transparent),
|
||||
linear-gradient(185deg, var(--color-brand-navy) 0%, var(--color-bg-primary) 55%);
|
||||
overflow: hidden;
|
||||
/* Prevent pull-to-refresh and overscroll bounce on mobile */
|
||||
overscroll-behavior: none;
|
||||
@@ -137,6 +145,9 @@ h6 {
|
||||
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
font-family: 'Montserrat', 'DM Sans', sans-serif;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
h3 {
|
||||
@@ -168,8 +179,9 @@ h4 {
|
||||
padding-left: max(var(--space-lg), var(--safe-area-inset-left));
|
||||
padding-right: max(var(--space-lg), var(--safe-area-inset-right));
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(12px);
|
||||
backdrop-filter: blur(14px);
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
@@ -190,12 +202,24 @@ h4 {
|
||||
fill: var(--color-accent);
|
||||
}
|
||||
|
||||
/* Logo imagen de marca (PNG/JPEG) */
|
||||
.navbar-logo-img {
|
||||
display: block;
|
||||
height: 42px;
|
||||
width: auto;
|
||||
max-width: 172px;
|
||||
object-fit: contain;
|
||||
object-position: left center;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 14px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
font-family: 'Century Gothic', sans-serif;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.03em;
|
||||
color: #fff;
|
||||
font-family: 'Montserrat', 'DM Sans', sans-serif;
|
||||
font-size: 1.45rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--color-text-primary);
|
||||
/* Visual correction: shift text slightly down to align optically with icon center */
|
||||
padding-top: 2px;
|
||||
}
|
||||
@@ -207,8 +231,8 @@ h4 {
|
||||
.version-badge {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
background: var(--color-accent);
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, var(--color-accent) 0%, #c9a875 100%);
|
||||
color: var(--color-on-accent);
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
margin-left: 8px;
|
||||
@@ -1492,13 +1516,15 @@ kbd {
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-accent);
|
||||
color: white;
|
||||
background: linear-gradient(135deg, var(--color-accent) 0%, #c9a875 100%);
|
||||
color: var(--color-on-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--color-accent-hover);
|
||||
background: linear-gradient(135deg, var(--color-accent-hover) 0%, var(--color-accent) 100%);
|
||||
box-shadow: var(--shadow-glow);
|
||||
color: var(--color-on-accent);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@@ -4415,9 +4441,9 @@ kbd {
|
||||
}
|
||||
|
||||
.user-badge-sso {
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 4px rgba(99, 102, 241, 0.3);
|
||||
background: linear-gradient(135deg, var(--color-accent) 0%, #c9a875 100%);
|
||||
color: var(--color-on-accent);
|
||||
box-shadow: 0 2px 4px rgba(232, 213, 181, 0.22);
|
||||
}
|
||||
|
||||
.user-badge-local {
|
||||
@@ -4448,24 +4474,24 @@ kbd {
|
||||
gap: 8px;
|
||||
padding: 6px 14px;
|
||||
margin-right: var(--space-md);
|
||||
background: linear-gradient(135deg, var(--color-accent) 0%, #8b5cf6 100%);
|
||||
background: linear-gradient(135deg, var(--color-accent) 0%, #c9a875 100%);
|
||||
border-radius: var(--radius-full);
|
||||
color: white;
|
||||
color: var(--color-on-accent);
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 200px;
|
||||
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.4);
|
||||
box-shadow: 0 2px 10px rgba(232, 213, 181, 0.22);
|
||||
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
|
||||
animation: now-playing-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.now-playing-indicator:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.5);
|
||||
box-shadow: 0 4px 14px rgba(232, 213, 181, 0.28);
|
||||
}
|
||||
|
||||
.now-playing-indicator.hidden {
|
||||
@@ -4819,4 +4845,45 @@ kbd {
|
||||
font-size: 0.7rem;
|
||||
max-width: 70px;
|
||||
}
|
||||
}
|
||||
}
|
||||
/* Electron Windows: mpv embutido (HWND) — ocultar overlays tras el vídeo nativo */
|
||||
body.kiratv-win-embedded #player-controls-overlay,
|
||||
body.kiratv-win-embedded #watch-overlay {
|
||||
visibility: hidden !important;
|
||||
}
|
||||
|
||||
/* WebView2 + panel vídeo Win32 — lista estrecha, reproductor fuera del motor web */
|
||||
body.kiratv-shell-wv2:not(.kiratv-shell-wv2-fullweb) #page-live .player-section { display: none !important; }
|
||||
body.kiratv-shell-wv2:not(.kiratv-shell-wv2-fullweb) #page-live .home-layout {
|
||||
display: block;
|
||||
}
|
||||
body.kiratv-shell-wv2:not(.kiratv-shell-wv2-fullweb) #page-live .channel-sidebar {
|
||||
width: 100% !important;
|
||||
max-width: none !important;
|
||||
}
|
||||
body.kiratv-shell-wv2-playing #player-controls-overlay,
|
||||
body.kiratv-shell-wv2-playing #watch-overlay {
|
||||
visibility: hidden !important;
|
||||
}
|
||||
|
||||
/* App de escritorio (Electron / WebView2): ancho completo; quita cajas centradas tipo max-width:1400px */
|
||||
body.kiratv-desktop-app #app {
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body.kiratv-desktop-app .dashboard-content {
|
||||
max-width: none !important;
|
||||
width: 100%;
|
||||
margin-left: 0 !important;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
|
||||
body.kiratv-desktop-app .settings-container {
|
||||
max-width: none !important;
|
||||
}
|
||||
|
||||
body.kiratv-desktop-app .watch-details-section {
|
||||
max-width: none !important;
|
||||
}
|
||||
|
||||
+159
-163
@@ -10,11 +10,11 @@
|
||||
<title>KiraTV | Televisión en el navegador</title>
|
||||
<meta name="description" content="TV en directo, películas y series con guía EPG — KiraTV">
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="stylesheet" href="css/main.css">
|
||||
<link rel="apple-touch-icon" href="/img/kiratv-logo-hero.jpg">
|
||||
<link rel="stylesheet" href="css/main.css?v=kira6">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Outfit:wght@800&display=swap"
|
||||
rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&family=Montserrat:wght@500;600;700;800&display=swap" rel="stylesheet">
|
||||
<!-- HLS.js for video playback -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/hls.js@1.5.7/dist/hls.min.js"></script>
|
||||
</head>
|
||||
@@ -24,15 +24,10 @@
|
||||
<!-- Navigation -->
|
||||
<nav class="navbar">
|
||||
<div class="navbar-brand">
|
||||
<span class="logo"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 187.63 210.6" fill="#6366f1"
|
||||
class="icon">
|
||||
<path
|
||||
d="M169.71,87.87c-4.77,0-9.1,1.88-12.31,4.92L35.3,22.3c.35-1.4.56-2.86.56-4.37C35.86,8.03,27.83,0,17.93,0S0,8.03,0,17.93c0,8.44,5.84,15.5,13.69,17.4v140c-7.71,2.01-13.41,8.99-13.41,17.33,0,9.9,8.03,17.93,17.93,17.93s17.93-8.03,17.93-17.93c0-1.56-.22-3.07-.6-4.51l121.21-69.98c3.26,3.42,7.86,5.55,12.95,5.55,9.9,0,17.93-8.03,17.93-17.93s-8.03-17.93-17.93-17.93ZM152.78,99.92c-.14.41-.27.83-.38,1.25h-51.47l-44.18-24.72L30.4,30.8c.33-.32.64-.65.94-.99l121.45,70.12ZM22.17,175.2V35.33c.23-.05.45-.11.67-.17l26.35,45.65-.34,50.04-25.74,44.59c-.31-.09-.63-.17-.95-.24ZM31.52,180.69c-.27-.3-.55-.6-.84-.89l25.48-44.13,44.64-25.78h51.47c.07.32.15.63.24.94L31.52,180.69Z" />
|
||||
</svg></span>
|
||||
<span class="brand-text">KiraTV</span>
|
||||
<img src="/img/kiratv-logo-hero.jpg" alt="KiraTV" class="navbar-logo-img" width="176" height="44" decoding="async">
|
||||
<span id="version-badge" class="version-badge"></span>
|
||||
</div>
|
||||
<button class="mobile-menu-toggle" id="mobile-menu-toggle" aria-label="Toggle menu">
|
||||
<button class="mobile-menu-toggle" id="mobile-menu-toggle" aria-label="Abrir o cerrar menú">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
@@ -41,14 +36,14 @@
|
||||
<!-- Now Playing indicator for movies/series -->
|
||||
<a href="#" class="now-playing-indicator hidden" id="now-playing-indicator" data-page="watch">
|
||||
<span class="now-playing-icon">▶</span>
|
||||
<span class="now-playing-text" id="now-playing-text">Now Playing</span>
|
||||
<span class="now-playing-text" id="now-playing-text">Reproduciendo</span>
|
||||
</a>
|
||||
<a href="#" class="nav-link active" data-page="home">
|
||||
<span class="nav-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
|
||||
class="icon">
|
||||
<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z" />
|
||||
</svg></span>
|
||||
<span>Home</span>
|
||||
<span>Inicio</span>
|
||||
</a>
|
||||
<a href="#" class="nav-link" data-page="live">
|
||||
<span class="nav-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
|
||||
@@ -56,14 +51,14 @@
|
||||
<path
|
||||
d="M21 6h-7.59l3.29-3.29L16 2l-4 4-4-4-.71.71L10.59 6H3a2 2 0 0 0-2 2v12c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V8a2 2 0 0 0-2-2zm0 14H3V8h18v12zM9 10v8l7-4z" />
|
||||
</svg></span>
|
||||
<span>Live TV</span>
|
||||
<span>TV en directo</span>
|
||||
</a>
|
||||
<a href="#" class="nav-link" data-page="guide">
|
||||
<span class="nav-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
|
||||
class="icon">
|
||||
<path d="M3 13h2v-2H3v2zm0 4h2v-2H3v2zm0-8h2V7H3v2zm4 4h14v-2H7v2zm0 4h14v-2H7v2zM7 7v2h14V7H7z" />
|
||||
</svg></span>
|
||||
<span>TV Guide</span>
|
||||
<span>Guía TV</span>
|
||||
</a>
|
||||
<a href="#" class="nav-link" data-page="movies">
|
||||
<span class="nav-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
|
||||
@@ -71,7 +66,7 @@
|
||||
<path
|
||||
d="M18 4l2 4h-3l-2-4h-2l2 4h-3l-2-4H8l2 4H7L5 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h20c1.1 0 2-.9 2-2V4h-4z" />
|
||||
</svg></span>
|
||||
<span>Movies</span>
|
||||
<span>Películas</span>
|
||||
</a>
|
||||
<a href="#" class="nav-link" data-page="series">
|
||||
<span class="nav-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
|
||||
@@ -87,7 +82,7 @@
|
||||
<path
|
||||
d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L3.16 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.04.64.09.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.58 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" />
|
||||
</svg></span>
|
||||
<span>Settings</span>
|
||||
<span>Ajustes</span>
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -97,7 +92,7 @@
|
||||
<!-- Home (Dashboard) Page -->
|
||||
<div id="page-home" class="page active">
|
||||
<!-- Content will be rendered by HomePage.js -->
|
||||
<div class="loading-state">Loading Dashboard...</div>
|
||||
<div class="loading-state">Cargando inicio…</div>
|
||||
</div>
|
||||
|
||||
<!-- Live TV Page (formerly Home) -->
|
||||
@@ -108,14 +103,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon">
|
||||
<path d="M3 13h2v-2H3v2zm0 4h2v-2H3v2zm0-8h2V7H3v2zm4 4h14v-2H7v2zm0 4h14v-2H7v2zM7 7v2h14V7H7z" />
|
||||
</svg>
|
||||
<span>Channels</span>
|
||||
<span>Canales</span>
|
||||
</button>
|
||||
|
||||
<!-- Overlay for drawer -->
|
||||
<div class="channel-sidebar-overlay" id="channel-sidebar-overlay"></div>
|
||||
|
||||
<!-- Expand button (visible when sidebar collapsed on desktop) -->
|
||||
<button class="sidebar-expand-btn" id="sidebar-expand-btn" title="Expand sidebar">
|
||||
<button class="sidebar-expand-btn" id="sidebar-expand-btn" title="Mostrar panel de canales">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon">
|
||||
<path d="M8.59 16.59L10 18l6-6-6-6-1.41 1.41L13.17 12z" />
|
||||
</svg>
|
||||
@@ -126,11 +121,11 @@
|
||||
<div class="sidebar-header">
|
||||
<div class="sidebar-header-row">
|
||||
<div class="search-wrapper">
|
||||
<input type="text" id="channel-search" placeholder="Search channels..." class="search-input">
|
||||
<button type="button" class="search-clear" title="Clear search">×</button>
|
||||
<input type="text" id="channel-search" placeholder="Buscar canales…" class="search-input">
|
||||
<button type="button" class="search-clear" title="Limpiar búsqueda">×</button>
|
||||
</div>
|
||||
<button id="sidebar-collapse-btn" class="btn btn-sm btn-ghost sidebar-collapse-btn"
|
||||
title="Collapse sidebar">
|
||||
title="Ocultar panel">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon">
|
||||
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z" />
|
||||
</svg>
|
||||
@@ -138,9 +133,9 @@
|
||||
</div>
|
||||
<div class="sidebar-controls">
|
||||
<select id="source-select" class="source-select">
|
||||
<option value="">All Sources</option>
|
||||
<option value="">Todas las fuentes</option>
|
||||
</select>
|
||||
<button id="toggle-groups" class="btn btn-sm btn-ghost" title="Expand/Collapse All">
|
||||
<button id="toggle-groups" class="btn btn-sm btn-ghost" title="Expandir o plegar todo">
|
||||
<span><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon">
|
||||
<path
|
||||
d="M12 5.83L15.17 9l1.41-1.41L12 3 7.41 7.59 8.83 9 12 5.83zm0 12.34L8.83 15l-1.41 1.41L12 21l4.59-4.59L15.17 15 12 18.17z" />
|
||||
@@ -151,8 +146,8 @@
|
||||
</div>
|
||||
<div id="channel-list" class="channel-list">
|
||||
<div class="empty-state">
|
||||
<p>No channels loaded</p>
|
||||
<p class="hint">Add a source in Settings to get started</p>
|
||||
<p>Cargando canales…</p>
|
||||
<p class="hint">Las categorías vienen plegadas: pulsa un nombre de grupo para ver los canales. Puedes usar «Expandir/plegar todo» arriba.</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -225,9 +220,9 @@
|
||||
</svg>
|
||||
</button>
|
||||
<div class="watch-captions-menu hidden" id="player-captions-menu">
|
||||
<div class="captions-menu-title">Subtitles</div>
|
||||
<div class="captions-menu-title">Subtítulos</div>
|
||||
<div class="captions-menu-list" id="player-captions-list">
|
||||
<button class="captions-option active" data-index="-1">Off</button>
|
||||
<button class="captions-option active" data-index="-1">Desactivados</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -257,7 +252,7 @@
|
||||
<path
|
||||
d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z" />
|
||||
</svg>
|
||||
Copy Stream URL
|
||||
Copiar URL del stream
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -267,18 +262,18 @@
|
||||
|
||||
<div class="player-overlay" id="player-overlay">
|
||||
<div class="overlay-content">
|
||||
<p>Select a channel to start watching</p>
|
||||
<p>Elige un canal para empezar</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Now Playing Overlay -->
|
||||
<div class="now-playing-overlay hidden" id="now-playing">
|
||||
<div class="now-playing-info">
|
||||
<h3 class="channel-name">No channel selected</h3>
|
||||
<h3 class="channel-name">Ningún canal seleccionado</h3>
|
||||
<p class="program-title"></p>
|
||||
<p class="program-time"></p>
|
||||
</div>
|
||||
<div class="up-next">
|
||||
<h4>Up Next</h4>
|
||||
<h4>A continuación</h4>
|
||||
<ul id="up-next-list"></ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -290,24 +285,24 @@
|
||||
<!-- Guide Page -->
|
||||
<div id="page-guide" class="page">
|
||||
<div class="guide-header">
|
||||
<h2>TV Guide</h2>
|
||||
<h2>Guía TV</h2>
|
||||
<div class="guide-controls">
|
||||
<select id="epg-group-select" class="source-select">
|
||||
<option value="">All Groups</option>
|
||||
<option value="">Todos los grupos</option>
|
||||
</select>
|
||||
<div class="search-wrapper">
|
||||
<input type="text" id="epg-search" placeholder="Search channels..." class="search-input">
|
||||
<button type="button" class="search-clear" title="Clear search">×</button>
|
||||
<input type="text" id="epg-search" placeholder="Buscar canales…" class="search-input">
|
||||
<button type="button" class="search-clear" title="Limpiar búsqueda">×</button>
|
||||
</div>
|
||||
<button id="guide-prev" class="btn btn-ghost">← Earlier</button>
|
||||
<span id="guide-date" class="guide-date">Today</span>
|
||||
<button id="guide-next" class="btn btn-ghost">Later →</button>
|
||||
<button id="guide-prev" class="btn btn-ghost">← Antes</button>
|
||||
<span id="guide-date" class="guide-date">Hoy</span>
|
||||
<button id="guide-next" class="btn btn-ghost">Después →</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="epg-grid" class="epg-grid">
|
||||
<div class="empty-state">
|
||||
<p>No EPG data available</p>
|
||||
<p class="hint">Add an EPG source in Settings</p>
|
||||
<p>No hay datos de guía (EPG)</p>
|
||||
<p class="hint">Añade una fuente EPG en Ajustes</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -315,29 +310,29 @@
|
||||
<!-- Movies Page -->
|
||||
<div id="page-movies" class="page">
|
||||
<div class="movies-header">
|
||||
<h2>Movies</h2>
|
||||
<h2>Películas</h2>
|
||||
<div class="movies-controls">
|
||||
<select id="movies-source-select" class="source-select">
|
||||
<option value="">All Sources</option>
|
||||
<option value="">Todas las fuentes</option>
|
||||
</select>
|
||||
<select id="movies-category-select" class="source-select">
|
||||
<option value="">All Categories</option>
|
||||
<option value="">Todas las categorías</option>
|
||||
</select>
|
||||
<div class="search-wrapper">
|
||||
<input type="text" id="movies-search" placeholder="Search movies..." class="search-input">
|
||||
<button type="button" class="search-clear" title="Clear search">×</button>
|
||||
<input type="text" id="movies-search" placeholder="Buscar películas…" class="search-input">
|
||||
<button type="button" class="search-clear" title="Limpiar búsqueda">×</button>
|
||||
</div>
|
||||
<button id="movies-favorites-btn" class="btn btn-icon btn-ghost" title="Show Favorites Only">
|
||||
<button id="movies-favorites-btn" class="btn btn-icon btn-ghost" title="Solo favoritos">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon">
|
||||
<path
|
||||
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
|
||||
</svg> Favorites
|
||||
</svg> Favoritos
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="movies-grid" class="movies-grid">
|
||||
<div class="empty-state">
|
||||
<p>Loading movies...</p>
|
||||
<p>Cargando películas…</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -348,20 +343,20 @@
|
||||
<h2>Series</h2>
|
||||
<div class="series-controls">
|
||||
<select id="series-source-select" class="source-select">
|
||||
<option value="">All Sources</option>
|
||||
<option value="">Todas las fuentes</option>
|
||||
</select>
|
||||
<select id="series-category-select" class="source-select">
|
||||
<option value="">All Categories</option>
|
||||
<option value="">Todas las categorías</option>
|
||||
</select>
|
||||
<div class="search-wrapper">
|
||||
<input type="text" id="series-search" placeholder="Search series..." class="search-input">
|
||||
<button type="button" class="search-clear" title="Clear search">×</button>
|
||||
<input type="text" id="series-search" placeholder="Buscar series…" class="search-input">
|
||||
<button type="button" class="search-clear" title="Limpiar búsqueda">×</button>
|
||||
</div>
|
||||
<button id="series-favorites-btn" class="btn btn-icon btn-ghost" title="Show Favorites Only">
|
||||
<button id="series-favorites-btn" class="btn btn-icon btn-ghost" title="Solo favoritos">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon">
|
||||
<path
|
||||
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
|
||||
</svg> Favorites
|
||||
</svg> Favoritos
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -369,12 +364,12 @@
|
||||
<div class="series-content">
|
||||
<div id="series-grid" class="series-grid">
|
||||
<div class="empty-state">
|
||||
<p>Loading series...</p>
|
||||
<p>Cargando series…</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Series details panel (seasons/episodes) -->
|
||||
<div id="series-details" class="series-details hidden">
|
||||
<button class="series-back-btn">← Back to Series</button>
|
||||
<button class="series-back-btn">← Volver a series</button>
|
||||
<div class="series-details-header">
|
||||
<img id="series-poster" src="" alt="" class="series-poster">
|
||||
<div class="series-info">
|
||||
@@ -390,59 +385,59 @@
|
||||
<!-- Settings Page -->
|
||||
<div id="page-settings" class="page">
|
||||
<div class="settings-container">
|
||||
<h2>Settings</h2>
|
||||
<h2>Ajustes</h2>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="tabs">
|
||||
<button class="tab active" data-tab="sources">Sources</button>
|
||||
<button class="tab" data-tab="player">Player</button>
|
||||
<button class="tab" data-tab="transcode">Transcoding</button>
|
||||
<button class="tab" data-tab="content">Manage Content</button>
|
||||
<button class="tab" data-tab="users" id="users-tab" style="display:none;">Users</button>
|
||||
<button class="tab active" data-tab="sources">Fuentes</button>
|
||||
<button class="tab" data-tab="player">Reproductor</button>
|
||||
<button class="tab" data-tab="transcode">Transcodificación</button>
|
||||
<button class="tab" data-tab="content">Contenido</button>
|
||||
<button class="tab" data-tab="users" id="users-tab" style="display:none;">Usuarios</button>
|
||||
</div>
|
||||
|
||||
<!-- Sources Tab -->
|
||||
<div id="tab-sources" class="tab-content active">
|
||||
<div class="source-section">
|
||||
<div class="section-header">
|
||||
<h3>Xtream Connections</h3>
|
||||
<button class="btn btn-primary" id="add-xtream">+ Add Xtream</button>
|
||||
<h3>Conexiones Xtream</h3>
|
||||
<button class="btn btn-primary" id="add-xtream">+ Añadir Xtream</button>
|
||||
</div>
|
||||
<div id="xtream-list" class="source-list"></div>
|
||||
</div>
|
||||
|
||||
<div class="source-section">
|
||||
<div class="section-header">
|
||||
<h3>M3U Playlists</h3>
|
||||
<button class="btn btn-primary" id="add-m3u">+ Add M3U</button>
|
||||
<h3>Listas M3U</h3>
|
||||
<button class="btn btn-primary" id="add-m3u">+ Añadir M3U</button>
|
||||
</div>
|
||||
<div id="m3u-list" class="source-list"></div>
|
||||
</div>
|
||||
|
||||
<div class="source-section">
|
||||
<div class="section-header">
|
||||
<h3>EPG Sources</h3>
|
||||
<button class="btn btn-primary" id="add-epg">+ Add EPG</button>
|
||||
<h3>Fuentes EPG</h3>
|
||||
<button class="btn btn-primary" id="add-epg">+ Añadir EPG</button>
|
||||
</div>
|
||||
<div id="epg-list" class="source-list"></div>
|
||||
</div>
|
||||
|
||||
<div class="source-section">
|
||||
<h3>EPG Data Settings</h3>
|
||||
<h3>Datos EPG</h3>
|
||||
<div class="setting-item">
|
||||
<label for="epg-refresh-interval">Auto-refresh EPG data every:</label>
|
||||
<label for="epg-refresh-interval">Actualizar datos EPG automáticamente cada:</label>
|
||||
<select id="epg-refresh-interval" class="form-input">
|
||||
<option value="1">1 hour</option>
|
||||
<option value="2">2 hours</option>
|
||||
<option value="4">4 hours</option>
|
||||
<option value="12">12 hours</option>
|
||||
<option value="24" selected>24 hours</option>
|
||||
<option value="0">Manual only</option>
|
||||
<option value="1">1 hora</option>
|
||||
<option value="2">2 horas</option>
|
||||
<option value="4">4 horas</option>
|
||||
<option value="12">12 horas</option>
|
||||
<option value="24" selected>24 horas</option>
|
||||
<option value="0">Solo manual</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<span class="setting-label">Last Refreshed:</span>
|
||||
<span id="epg-last-refreshed" class="hint">Never</span>
|
||||
<span class="setting-label">Última actualización:</span>
|
||||
<span id="epg-last-refreshed" class="hint">Nunca</span>
|
||||
</div>
|
||||
<p class="hint">Use the <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
|
||||
class="icon" style="width: 1em; height: 1em; vertical-align: middle;">
|
||||
@@ -455,32 +450,32 @@
|
||||
<!-- Player Tab -->
|
||||
<div id="tab-player" class="tab-content">
|
||||
<div class="settings-section">
|
||||
<h3>Keyboard Controls</h3>
|
||||
<h3>Teclado</h3>
|
||||
<div class="setting-item">
|
||||
<label class="setting-toggle">
|
||||
<input type="checkbox" id="setting-arrow-keys" checked>
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">Arrow keys change channels</span>
|
||||
<span class="setting-hint">When enabled, ↑/↓ change channels and ←/→ adjust volume</span>
|
||||
<span class="setting-label">Las flechas cambian de canal</span>
|
||||
<span class="setting-hint">Si está activo: ↑/↓ cambian canal y ←/→ ajustan volumen</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h3>Now Playing Overlay</h3>
|
||||
<h3>Overlay «Reproduciendo»</h3>
|
||||
<div class="setting-item">
|
||||
<label for="setting-overlay-duration">Display Duration (seconds)</label>
|
||||
<label for="setting-overlay-duration">Duración en pantalla (segundos)</label>
|
||||
<input type="number" id="setting-overlay-duration" class="form-input setting-number" min="1" max="30"
|
||||
value="5">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h3>Volume Settings</h3>
|
||||
<h3>Volumen</h3>
|
||||
<div class="setting-item">
|
||||
<label for="setting-default-volume">Default Volume</label>
|
||||
<label for="setting-default-volume">Volumen por defecto</label>
|
||||
<div class="volume-slider-container">
|
||||
<input type="range" id="setting-default-volume" class="volume-slider" min="0" max="100" value="80">
|
||||
<span id="volume-value">80%</span>
|
||||
@@ -492,36 +487,36 @@
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">Remember Last Volume</span>
|
||||
<span class="setting-hint">When enabled, volume persists between sessions</span>
|
||||
<span class="setting-label">Recordar último volumen</span>
|
||||
<span class="setting-hint">Si está activo, el volumen se conserva entre sesiones</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h3>Playback</h3>
|
||||
<h3>Reproducción</h3>
|
||||
<div class="setting-item">
|
||||
<label class="setting-toggle">
|
||||
<input type="checkbox" id="setting-autoplay-next">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">Auto-play Next Episode</span>
|
||||
<span class="setting-hint">Automatically play the next episode when one ends</span>
|
||||
<span class="setting-label">Autoplay del siguiente episodio</span>
|
||||
<span class="setting-hint">Cuando termine un episodio, pasa automáticamente al siguiente</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h3>Keyboard Shortcuts</h3>
|
||||
<h3>Atajos de teclado</h3>
|
||||
<div class="shortcuts-grid">
|
||||
<div class="shortcut"><kbd>↑</kbd> / <kbd>↓</kbd> Channel up/down (or volume)</div>
|
||||
<div class="shortcut"><kbd>←</kbd> / <kbd>→</kbd> Volume down/up (when arrows = channels)</div>
|
||||
<div class="shortcut"><kbd>Space</kbd> / <kbd>K</kbd> Play/Pause</div>
|
||||
<div class="shortcut"><kbd>F</kbd> Fullscreen</div>
|
||||
<div class="shortcut"><kbd>M</kbd> Mute/Unmute</div>
|
||||
<div class="shortcut"><kbd>I</kbd> Toggle info overlay</div>
|
||||
<div class="shortcut"><kbd>PgUp</kbd> / <kbd>PgDn</kbd> Channel up/down</div>
|
||||
<div class="shortcut"><kbd>↑</kbd> / <kbd>↓</kbd> Canal anterior/siguiente (o volumen)</div>
|
||||
<div class="shortcut"><kbd>←</kbd> / <kbd>→</kbd> Bajar/subir volumen (si flechas = canales)</div>
|
||||
<div class="shortcut"><kbd>Espacio</kbd> / <kbd>K</kbd> Reproducir / Pausa</div>
|
||||
<div class="shortcut"><kbd>F</kbd> Pantalla completa</div>
|
||||
<div class="shortcut"><kbd>M</kbd> Silenciar / activar sonido</div>
|
||||
<div class="shortcut"><kbd>I</kbd> Mostrar u ocultar información</div>
|
||||
<div class="shortcut"><kbd>Re Pág</kbd> / <kbd>Av Pág</kbd> Canal anterior/siguiente</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -529,14 +524,14 @@
|
||||
<!-- Transcoding Tab -->
|
||||
<div id="tab-transcode" class="tab-content">
|
||||
<div class="settings-section">
|
||||
<h3>Detected Hardware</h3>
|
||||
<h3>Hardware detectado</h3>
|
||||
<div id="hw-info-container" class="hw-info-container">
|
||||
<p class="hint">Loading hardware information...</p>
|
||||
<p class="hint">Cargando información del hardware…</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h3>Encoder Settings</h3>
|
||||
<h3>Codificador</h3>
|
||||
<div class="setting-item">
|
||||
<div class="setting-info" style="flex: 1;">
|
||||
<span class="setting-label">Hardware Encoder</span>
|
||||
@@ -678,12 +673,12 @@
|
||||
<div class="setting-item">
|
||||
<div class="setting-info" style="flex: 1;">
|
||||
<span class="setting-label">Stream Output Format</span>
|
||||
<span class="setting-hint">Container format for Xtream streams. Try TS if you experience buffering
|
||||
issues.</span>
|
||||
<span class="setting-hint">Para Dispatcharr: elige <strong>TS</strong> (aunque la URL lleve .m3u8, suele
|
||||
ser video MPEG-TS; HLS falla en el navegador).</span>
|
||||
</div>
|
||||
<select id="setting-stream-format-tc" class="source-select" style="width: auto; min-width: 200px;">
|
||||
<option value="m3u8">HLS (m3u8) - Recommended</option>
|
||||
<option value="ts">MPEG-TS (ts) - Better buffer</option>
|
||||
<option value="ts">MPEG-TS (ts) — recomendado con Dispatcharr</option>
|
||||
<option value="m3u8">HLS (m3u8) — solo si el proveedor es manifiesto real</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -734,30 +729,30 @@
|
||||
<div class="content-browser">
|
||||
<div class="content-browser-header">
|
||||
<div class="content-type-toggle">
|
||||
<button class="btn btn-sm active" id="content-type-channels">Channels</button>
|
||||
<button class="btn btn-sm" id="content-type-movies">Movies</button>
|
||||
<button class="btn btn-sm active" id="content-type-channels">Canales</button>
|
||||
<button class="btn btn-sm" id="content-type-movies">Películas</button>
|
||||
<button class="btn btn-sm" id="content-type-series">Series</button>
|
||||
</div>
|
||||
<select id="content-source-select" class="source-select">
|
||||
<option value="">Select a source...</option>
|
||||
<option value="">Selecciona una fuente…</option>
|
||||
</select>
|
||||
<div class="search-wrapper">
|
||||
<input type="text" id="content-search" placeholder="Search groups/channels..." class="search-input">
|
||||
<button type="button" class="search-clear" title="Clear search">×</button>
|
||||
<input type="text" id="content-search" placeholder="Buscar grupos o canales…" class="search-input">
|
||||
<button type="button" class="search-clear" title="Limpiar búsqueda">×</button>
|
||||
</div>
|
||||
<div class="content-actions">
|
||||
<button class="btn btn-sm btn-secondary" id="content-show-all">Show All</button>
|
||||
<button class="btn btn-sm btn-secondary" id="content-hide-all">Hide All</button>
|
||||
<button class="btn btn-sm btn-secondary" id="content-show-all">Mostrar todo</button>
|
||||
<button class="btn btn-sm btn-secondary" id="content-hide-all">Ocultar todo</button>
|
||||
<button class="btn btn-sm btn-primary" id="content-save"><svg xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24" fill="currentColor" class="icon">
|
||||
<path
|
||||
d="M17 3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V7l-4-4zm-5 16c-1.66 0-3-1.34-3-3s1.34-3 3-3 3 1.34 3 3-1.34 3-3 3zm3-10H5V5h10v4z" />
|
||||
</svg> Save Changes</button>
|
||||
</svg> Guardar cambios</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint" style="margin-bottom: var(--space-sm);">✓ Checked = Visible, ☐ Unchecked = Hidden</p>
|
||||
<p class="hint" style="margin-bottom: var(--space-sm);">✓ Marcado = visible · ☐ Sin marcar = oculto</p>
|
||||
<div id="content-tree" class="content-tree">
|
||||
<p class="hint">Select a source to view groups and channels</p>
|
||||
<p class="hint">Selecciona una fuente para ver grupos y canales</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -765,47 +760,47 @@
|
||||
<!-- Users Tab (Admin Only) -->
|
||||
<div id="tab-users" class="tab-content">
|
||||
<div class="settings-section">
|
||||
<h3>User Management</h3>
|
||||
<p class="hint">Manage user accounts and permissions</p>
|
||||
<h3>Usuarios</h3>
|
||||
<p class="hint">Gestiona cuentas y permisos</p>
|
||||
|
||||
<div class="user-list-container">
|
||||
<table class="user-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Email</th>
|
||||
<th>Role</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
<th>Usuario</th>
|
||||
<th>Correo</th>
|
||||
<th>Rol</th>
|
||||
<th>Creado</th>
|
||||
<th>Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="user-list">
|
||||
<tr>
|
||||
<td colspan="4" class="hint">Loading users...</td>
|
||||
<td colspan="4" class="hint">Cargando usuarios…</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="add-user-section">
|
||||
<h4>Add New User</h4>
|
||||
<h4>Añadir usuario</h4>
|
||||
<form id="add-user-form" class="user-form">
|
||||
<div class="form-group">
|
||||
<label for="new-username">Username</label>
|
||||
<label for="new-username">Usuario</label>
|
||||
<input type="text" id="new-username" name="username" class="form-input" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new-password">Password</label>
|
||||
<label for="new-password">Contraseña</label>
|
||||
<input type="password" id="new-password" name="password" class="form-input" required minlength="6">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new-role">Role</label>
|
||||
<label for="new-role">Rol</label>
|
||||
<select id="new-role" name="role" class="form-input" required>
|
||||
<option value="viewer">Viewer</option>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="viewer">Espectador</option>
|
||||
<option value="admin">Administrador</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Add User</button>
|
||||
<button type="submit" class="btn btn-primary">Añadir usuario</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -823,7 +818,7 @@
|
||||
<div class="watch-overlay" id="watch-overlay">
|
||||
<!-- Top Bar: Back + Title -->
|
||||
<div class="watch-top-bar vod-header">
|
||||
<button class="watch-back-btn" id="watch-back-btn" title="Back">
|
||||
<button class="watch-back-btn" id="watch-back-btn" title="Volver">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon">
|
||||
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
|
||||
</svg>
|
||||
@@ -901,9 +896,9 @@
|
||||
</svg>
|
||||
</button>
|
||||
<div class="watch-captions-menu hidden" id="watch-captions-menu">
|
||||
<div class="captions-menu-title">Subtitles</div>
|
||||
<div class="captions-menu-title">Subtítulos</div>
|
||||
<div class="captions-menu-list" id="watch-captions-list">
|
||||
<button class="captions-option active" data-index="-1">Off</button>
|
||||
<button class="captions-option active" data-index="-1">Desactivados</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -932,7 +927,7 @@
|
||||
<path
|
||||
d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z" />
|
||||
</svg>
|
||||
Copy Stream URL
|
||||
Copiar URL del stream
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -941,7 +936,7 @@
|
||||
|
||||
<!-- Scroll Indicator -->
|
||||
<div class="watch-scroll-hint" id="watch-scroll-hint">
|
||||
<span>Scroll for details</span>
|
||||
<span>Desplázate para ver detalles</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon">
|
||||
<path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z" />
|
||||
</svg>
|
||||
@@ -951,12 +946,12 @@
|
||||
<!-- Next Episode Panel -->
|
||||
<div class="watch-next-episode hidden" id="watch-next-episode">
|
||||
<div class="next-info">
|
||||
<p class="next-label">Up Next</p>
|
||||
<p class="next-label">Siguiente</p>
|
||||
<h4 id="next-episode-title"></h4>
|
||||
</div>
|
||||
<span class="next-countdown" id="next-countdown">10</span>
|
||||
<button class="btn btn-primary btn-sm" id="next-play-now">Play Now</button>
|
||||
<button class="btn btn-ghost btn-sm" id="next-cancel">Cancel</button>
|
||||
<button class="btn btn-primary btn-sm" id="next-play-now">Reproducir ya</button>
|
||||
<button class="btn btn-ghost btn-sm" id="next-cancel">Cancelar</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -978,7 +973,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
<span id="watch-play-btn-text">Play</span>
|
||||
<span id="watch-play-btn-text">Reproducir</span>
|
||||
</button>
|
||||
<button class="btn btn-ghost" id="watch-favorite-btn">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
|
||||
@@ -991,7 +986,7 @@
|
||||
<path
|
||||
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
|
||||
</svg>
|
||||
<span>Favorite</span>
|
||||
<span>Favorito</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -999,7 +994,7 @@
|
||||
|
||||
<!-- Movies: Recommended Section -->
|
||||
<div class="watch-recommended hidden" id="watch-recommended">
|
||||
<h3>More Like This</h3>
|
||||
<h3>Te puede gustar</h3>
|
||||
<div class="watch-recommended-grid" id="watch-recommended-grid">
|
||||
<!-- Movie cards inserted dynamically -->
|
||||
</div>
|
||||
@@ -1007,7 +1002,7 @@
|
||||
|
||||
<!-- Series: Seasons & Episodes -->
|
||||
<div class="watch-episodes hidden" id="watch-episodes">
|
||||
<h3>Episodes</h3>
|
||||
<h3>Episodios</h3>
|
||||
<div class="watch-seasons" id="watch-seasons">
|
||||
<!-- Season accordions inserted dynamically -->
|
||||
</div>
|
||||
@@ -1022,7 +1017,7 @@
|
||||
<div id="modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 id="modal-title">Modal</h3>
|
||||
<h3 id="modal-title">Ventana</h3>
|
||||
<button class="modal-close">×</button>
|
||||
</div>
|
||||
<div id="modal-body" class="modal-body"></div>
|
||||
@@ -1035,19 +1030,19 @@
|
||||
<button class="context-item" data-action="play"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
|
||||
fill="currentColor" class="icon">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg> Play</button>
|
||||
</svg> Reproducir</button>
|
||||
<button class="context-item" data-action="hide"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
|
||||
fill="currentColor" class="icon">
|
||||
<path
|
||||
d="M12 7c2.76 0 5 2.24 5 5 0 .65-.13 1.26-.36 1.83l2.92 2.92c1.51-1.26 2.7-2.89 3.43-4.75-1.73-4.39-6-7.5-11-7.5-1.4 0-2.74.25-4.01.7l2.16 2.16C10.85 7.18 11.4 7 12 7zM2 4.27l2.28 2.28.46.46C3.08 8.3 1.78 10.02 1 12c1.73 4.39 6 7.5 11 7.5 1.55 0 3.03-.3 4.38-.84l.42.42L19.73 22 21 20.73 3.27 3 2 4.27zM7.53 9.8l1.55 1.55c-.05.21-.08.43-.08.65 0 1.66 1.34 3 3 3 .22 0 .44-.03.65-.08l1.55 1.55c-.67.33-1.41.53-2.2.53-2.76 0-5-2.24-5-5 0-.79.2-1.53.53-2.2zm4.31-.78l3.15 3.15.02-.16c0-1.66-1.34-3-3-3l-.17.01z" />
|
||||
</svg> Hide</button>
|
||||
</svg> Ocultar</button>
|
||||
</div>
|
||||
|
||||
<!-- Edit User Modal -->
|
||||
<div class="modal-overlay" id="edit-user-modal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">Edit User</h3>
|
||||
<h3 class="modal-title">Editar usuario</h3>
|
||||
<button class="modal-close" id="edit-user-close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@@ -1055,41 +1050,41 @@
|
||||
<input type="hidden" id="edit-user-id">
|
||||
|
||||
<div class="modal-form-group">
|
||||
<label for="edit-username">Username</label>
|
||||
<label for="edit-username">Usuario</label>
|
||||
<input type="text" id="edit-username" name="username" class="form-input" required>
|
||||
</div>
|
||||
|
||||
<div class="modal-form-group">
|
||||
<label for="edit-email">Email</label>
|
||||
<input type="text" id="edit-email" class="form-input" readonly disabled placeholder="No email associated">
|
||||
<label for="edit-email">Correo</label>
|
||||
<input type="text" id="edit-email" class="form-input" readonly disabled placeholder="Sin correo asociado">
|
||||
</div>
|
||||
|
||||
<div class="modal-form-group">
|
||||
<label for="edit-role">Role</label>
|
||||
<label for="edit-role">Rol</label>
|
||||
<select id="edit-role" name="role" class="form-input" required>
|
||||
<option value="viewer">Viewer</option>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="viewer">Espectador</option>
|
||||
<option value="admin">Administrador</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="modal-form-group">
|
||||
<label for="edit-password">New Password</label>
|
||||
<label for="edit-password">Nueva contraseña</label>
|
||||
<input type="password" id="edit-password" name="password" class="form-input"
|
||||
placeholder="Leave blank to keep current">
|
||||
<p class="hint" id="edit-password-hint" style="margin-top: 4px;">Optional. Leave blank to keep unchanged.
|
||||
placeholder="Dejar en blanco para no cambiar">
|
||||
<p class="hint" id="edit-password-hint" style="margin-top: 4px;">Opcional. Déjalo en blanco para mantener la actual.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="modal-form-group hidden" id="oidc-info-group">
|
||||
<label>OIDC Provider ID</label>
|
||||
<label>ID de proveedor OIDC</label>
|
||||
<code id="edit-oidc-id"
|
||||
style="font-size: 0.8rem; color: var(--color-text-muted); padding: 4px; background: rgba(0,0,0,0.3); border-radius: 4px; display: block;"></code>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-ghost" id="edit-user-cancel">Cancel</button>
|
||||
<button class="btn btn-primary" id="edit-user-save">Save Changes</button>
|
||||
<button class="btn btn-ghost" id="edit-user-cancel">Cancelar</button>
|
||||
<button class="btn btn-primary" id="edit-user-save">Guardar cambios</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1097,7 +1092,8 @@
|
||||
<!-- JavaScript -->
|
||||
<script src="/js/icons.js"></script>
|
||||
<script src="/js/api.js?v=2"></script>
|
||||
<script src="/js/components/VideoPlayer.js?v=2"></script>
|
||||
<script src="/js/desktopPlayback.js?v=9"></script>
|
||||
<script src="/js/components/VideoPlayer.js?v=6"></script>
|
||||
<script src="/js/components/ChannelList.js?v=3"></script>
|
||||
<script src="/js/components/SourceManager.js?v=2"></script>
|
||||
<script src="/js/components/EpgGuide.js?v=2"></script>
|
||||
@@ -1106,9 +1102,9 @@
|
||||
<script src="/js/pages/Guide.js?v=2"></script>
|
||||
<script src="/js/pages/MoviesPage.js?v=4"></script>
|
||||
<script src="/js/pages/SeriesPage.js?v=1"></script>
|
||||
<script src="/js/pages/Settings.js?v=2"></script>
|
||||
<script src="/js/pages/WatchPage.js?v=1"></script>
|
||||
<script src="/js/app.js?v=4"></script>
|
||||
<script src="/js/pages/Settings.js?v=3"></script>
|
||||
<script src="/js/pages/WatchPage.js?v=3"></script>
|
||||
<script src="/js/app.js?v=5"></script>
|
||||
<script>
|
||||
// Check for SSO token in URL
|
||||
(function () {
|
||||
|
||||
+1
-1
@@ -136,7 +136,7 @@ const API = {
|
||||
seriesInfo: (sourceId, seriesId) =>
|
||||
API.request('GET', `/proxy/xtream/${sourceId}/series_info?series_id=${seriesId}`),
|
||||
shortEpg: (sourceId, streamId) => API.request('GET', `/proxy/xtream/${sourceId}/short_epg?stream_id=${streamId}`),
|
||||
getStreamUrl: (sourceId, streamId, type = 'live', container = 'm3u8') =>
|
||||
getStreamUrl: (sourceId, streamId, type = 'live', container = 'ts') =>
|
||||
API.request('GET', `/proxy/xtream/${sourceId}/stream/${streamId}/${type}?container=${container}`)
|
||||
},
|
||||
|
||||
|
||||
@@ -150,6 +150,11 @@ class App {
|
||||
// Initialize home page first (it's needed for channel list)
|
||||
await this.pages.home.init();
|
||||
|
||||
// La barra lateral es global: antes solo se cargaban canales al abrir la página «Live»
|
||||
// (LivePage.init no se llama en ningún sitio). Sin esto, en Inicio parece que «no hay canales».
|
||||
await this.channelList.loadSources();
|
||||
await this.channelList.loadChannels();
|
||||
|
||||
// Preload EPG data in background (non-blocking)
|
||||
// This ensures EPG info is available on Live TV page without visiting Guide first
|
||||
this.epgGuide.loadEpg().catch(err => {
|
||||
|
||||
@@ -1159,7 +1159,7 @@ class ChannelList {
|
||||
let streamUrl;
|
||||
if (channel.sourceType === 'xtream') {
|
||||
// Get stream format from player settings (server-side) or fallback
|
||||
const streamFormat = window.app?.player?.settings?.streamFormat || 'm3u8';
|
||||
const streamFormat = window.app?.player?.settings?.streamFormat || 'ts';
|
||||
const result = await API.proxy.xtream.getStreamUrl(channel.sourceId, channel.streamId, 'live', streamFormat);
|
||||
streamUrl = result.url;
|
||||
} else {
|
||||
|
||||
@@ -8,6 +8,8 @@ class SourceManager {
|
||||
this.xtreamList = document.getElementById('xtream-list');
|
||||
this.m3uList = document.getElementById('m3u-list');
|
||||
this.epgList = document.getElementById('epg-list');
|
||||
/** @type {Record<number, string>} id → username (admin, for labels) */
|
||||
this.userNamesById = {};
|
||||
|
||||
// Content browser state
|
||||
this.contentType = 'channels'; // 'channels' or 'movies'
|
||||
@@ -20,6 +22,29 @@ class SourceManager {
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Comprueba si la sesión es admin. Usa /api/auth/me si window.app.currentUser
|
||||
* aún no está (carrera: el modal se abre antes de que termine checkAuth).
|
||||
*/
|
||||
async isAdminSession() {
|
||||
let role = window.app?.currentUser?.role;
|
||||
if (!role && typeof localStorage !== 'undefined') {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (token) {
|
||||
try {
|
||||
const r = await fetch('/api/auth/me', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
if (r.ok) {
|
||||
const u = await r.json();
|
||||
role = u.role;
|
||||
}
|
||||
} catch (_) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
return String(role || '').toLowerCase() === 'admin';
|
||||
}
|
||||
|
||||
init() {
|
||||
// Add source buttons
|
||||
document.getElementById('add-xtream').addEventListener('click', () => this.showAddModal('xtream'));
|
||||
@@ -112,9 +137,20 @@ class SourceManager {
|
||||
try {
|
||||
const sources = await API.sources.getAll();
|
||||
|
||||
this.renderSourceList(this.xtreamList, sources.filter(s => s.type === 'xtream'), 'xtream');
|
||||
this.renderSourceList(this.m3uList, sources.filter(s => s.type === 'm3u'), 'm3u');
|
||||
this.renderSourceList(this.epgList, sources.filter(s => s.type === 'epg'), 'epg');
|
||||
const admin = await this.isAdminSession();
|
||||
this.userNamesById = {};
|
||||
if (admin) {
|
||||
try {
|
||||
const users = await API.users.getAll();
|
||||
users.forEach(u => { this.userNamesById[u.id] = u.username; });
|
||||
} catch (e) {
|
||||
console.warn('[SourceManager] Could not load users for labels:', e);
|
||||
}
|
||||
}
|
||||
|
||||
this.renderSourceList(this.xtreamList, sources.filter(s => s.type === 'xtream'), 'xtream', admin);
|
||||
this.renderSourceList(this.m3uList, sources.filter(s => s.type === 'm3u'), 'm3u', admin);
|
||||
this.renderSourceList(this.epgList, sources.filter(s => s.type === 'epg'), 'epg', admin);
|
||||
} catch (err) {
|
||||
console.error('Error loading sources:', err);
|
||||
}
|
||||
@@ -123,7 +159,7 @@ class SourceManager {
|
||||
/**
|
||||
* Render source list
|
||||
*/
|
||||
renderSourceList(container, sources, type) {
|
||||
renderSourceList(container, sources, type, isAdmin = false) {
|
||||
if (sources.length === 0) {
|
||||
container.innerHTML = `<p class="hint">No ${type.toUpperCase()} sources configured</p>`;
|
||||
return;
|
||||
@@ -131,12 +167,21 @@ class SourceManager {
|
||||
|
||||
const icons = { xtream: Icons.live, m3u: Icons.guide, epg: Icons.series };
|
||||
|
||||
const esc = (t) => String(t ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/"/g, '"');
|
||||
const admin = isAdmin;
|
||||
const ownerLine = admin
|
||||
? `<div class="source-meta hint">${source.ownerId != null
|
||||
? `Propietario: ${esc(this.userNamesById[source.ownerId] || ('#' + source.ownerId))}`
|
||||
: 'Fuente compartida (todos los usuarios)'}</div>`
|
||||
: '';
|
||||
|
||||
container.innerHTML = sources.map(source => `
|
||||
<div class="source-item ${source.enabled ? '' : 'disabled'}" data-id="${source.id}">
|
||||
<span class="source-icon">${icons[type]}</span>
|
||||
<div class="source-info">
|
||||
<div class="source-name">${source.name}</div>
|
||||
<div class="source-url">${source.url}</div>
|
||||
<div class="source-name">${esc(source.name)}</div>
|
||||
<div class="source-url">${esc(source.url)}</div>
|
||||
${ownerLine}
|
||||
</div>
|
||||
<div class="source-actions">
|
||||
<button class="btn btn-sm btn-secondary" data-action="refresh" title="Refresh Data">${Icons.refresh}</button>
|
||||
@@ -165,7 +210,7 @@ class SourceManager {
|
||||
/**
|
||||
* Show add source modal
|
||||
*/
|
||||
showAddModal(type) {
|
||||
async showAddModal(type) {
|
||||
const modal = document.getElementById('modal');
|
||||
const title = document.getElementById('modal-title');
|
||||
const body = document.getElementById('modal-body');
|
||||
@@ -174,7 +219,7 @@ class SourceManager {
|
||||
const titles = { xtream: 'Add Xtream Connection', m3u: 'Add M3U Playlist', epg: 'Add EPG Source' };
|
||||
title.textContent = titles[type];
|
||||
|
||||
body.innerHTML = this.getSourceForm(type);
|
||||
body.innerHTML = await this.getSourceForm(type);
|
||||
|
||||
footer.innerHTML = `
|
||||
<button class="btn btn-secondary" id="modal-cancel">Cancel</button>
|
||||
@@ -202,7 +247,7 @@ class SourceManager {
|
||||
const footer = document.getElementById('modal-footer');
|
||||
|
||||
title.textContent = `Edit ${type.toUpperCase()} Source`;
|
||||
body.innerHTML = this.getSourceForm(type, source);
|
||||
body.innerHTML = await this.getSourceForm(type, source);
|
||||
|
||||
footer.innerHTML = `
|
||||
<button class="btn btn-secondary" id="modal-cancel">Cancel</button>
|
||||
@@ -222,11 +267,32 @@ class SourceManager {
|
||||
/**
|
||||
* Get source form HTML
|
||||
*/
|
||||
getSourceForm(type, source = {}) {
|
||||
async getSourceForm(type, source = {}) {
|
||||
const esc = (t) => String(t ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/"/g, '"');
|
||||
|
||||
let ownerField = '';
|
||||
if (await this.isAdminSession()) {
|
||||
let users = [];
|
||||
try {
|
||||
users = await API.users.getAll();
|
||||
} catch (e) {
|
||||
console.warn('[SourceManager] users list:', e);
|
||||
}
|
||||
const opts = [`<option value="">Compartida (todos los usuarios autenticados)</option>`].concat(
|
||||
users.map(u => `<option value="${u.id}" ${Number(source.ownerId) === Number(u.id) ? 'selected' : ''}>${esc(u.username)}</option>`)
|
||||
);
|
||||
ownerField = `
|
||||
<div class="form-group">
|
||||
<label for="source-owner">Propietario</label>
|
||||
<select id="source-owner" class="form-input">${opts.join('')}</select>
|
||||
<p class="hint">Si asignas un usuario, solo él podrá usar esta fuente en el proxy (evita doble consumo de conexiones XC).</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const nameField = `
|
||||
<div class="form-group">
|
||||
<label for="source-name">Name</label>
|
||||
<input type="text" id="source-name" class="form-input" placeholder="My Source" value="${source.name || ''}">
|
||||
<input type="text" id="source-name" class="form-input" placeholder="My Source" value="${esc(source.name)}">
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -235,27 +301,28 @@ class SourceManager {
|
||||
<label for="source-url">${type === 'xtream' ? 'Server URL' : 'URL'}</label>
|
||||
<input type="text" id="source-url" class="form-input"
|
||||
placeholder="${type === 'xtream' ? 'http://server.com:port' : 'https://example.com/playlist.m3u'}"
|
||||
value="${source.url || ''}">
|
||||
value="${esc(source.url)}">
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (type === 'xtream') {
|
||||
const pwVal = source.password && !String(source.password).includes('•') ? esc(source.password) : '';
|
||||
return `
|
||||
${ownerField}
|
||||
${nameField}
|
||||
${urlField}
|
||||
<div class="form-group">
|
||||
<label for="source-username">Username</label>
|
||||
<input type="text" id="source-username" class="form-input" value="${source.username || ''}">
|
||||
<input type="text" id="source-username" class="form-input" value="${esc(source.username)}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="source-password">Password</label>
|
||||
<input type="password" id="source-password" class="form-input"
|
||||
value="${source.password && !source.password.includes('•') ? source.password : ''}">
|
||||
<input type="password" id="source-password" class="form-input" value="${pwVal}">
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return nameField + urlField;
|
||||
return ownerField + nameField + urlField;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -295,7 +362,15 @@ class SourceManager {
|
||||
}
|
||||
}
|
||||
|
||||
await API.sources.create({ type, name, url, username, password });
|
||||
const payload = { type, name, url, username, password };
|
||||
if (await this.isAdminSession()) {
|
||||
const sel = document.getElementById('source-owner');
|
||||
if (sel) {
|
||||
const v = sel.value;
|
||||
payload.ownerId = v === '' ? null : parseInt(v, 10);
|
||||
}
|
||||
}
|
||||
await API.sources.create(payload);
|
||||
document.getElementById('modal').classList.remove('active');
|
||||
await this.loadSources();
|
||||
|
||||
@@ -329,6 +404,13 @@ class SourceManager {
|
||||
data.username = username;
|
||||
if (password) data.password = password;
|
||||
}
|
||||
if (await this.isAdminSession()) {
|
||||
const sel = document.getElementById('source-owner');
|
||||
if (sel) {
|
||||
const v = sel.value;
|
||||
data.ownerId = v === '' ? null : parseInt(v, 10);
|
||||
}
|
||||
}
|
||||
|
||||
await API.sources.update(id, data);
|
||||
document.getElementById('modal').classList.remove('active');
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
})();
|
||||
@@ -130,7 +130,7 @@ class SettingsPage {
|
||||
if (forceTranscodeToggle) forceTranscodeToggle.checked = s.forceTranscode === true;
|
||||
if (forceVideoTranscodeToggle) forceVideoTranscodeToggle.checked = s.forceVideoTranscode === true;
|
||||
if (forceRemuxToggle) forceRemuxToggle.checked = s.forceRemux || false;
|
||||
if (streamFormatSelect) streamFormatSelect.value = s.streamFormat || 'm3u8';
|
||||
if (streamFormatSelect) streamFormatSelect.value = s.streamFormat || 'ts';
|
||||
if (userAgentSelect) userAgentSelect.value = s.userAgentPreset || 'chrome';
|
||||
if (userAgentCustomInput) userAgentCustomInput.value = s.userAgentCustom || '';
|
||||
if (customUaContainer) {
|
||||
@@ -575,7 +575,7 @@ class SettingsPage {
|
||||
if (forceRemuxToggle) forceRemuxToggle.checked = s.forceRemux || false;
|
||||
if (autoTranscodeToggle) autoTranscodeToggle.checked = s.autoTranscode || false;
|
||||
if (epgRefreshSelect) epgRefreshSelect.value = s.epgRefreshInterval || '24';
|
||||
if (streamFormatSelect) streamFormatSelect.value = s.streamFormat || 'm3u8';
|
||||
if (streamFormatSelect) streamFormatSelect.value = s.streamFormat || 'ts';
|
||||
|
||||
// User-Agent settings
|
||||
const userAgentSelect = document.getElementById('setting-user-agent');
|
||||
|
||||
+26
-252
@@ -79,11 +79,6 @@ class WatchPage {
|
||||
this.isFavorite = false;
|
||||
this.returnPage = null;
|
||||
this.captionsMenuOpen = false;
|
||||
this.sourceStreamUrl = null;
|
||||
this.knownDurationSec = 0;
|
||||
this.playbackBaseOffsetSec = 0;
|
||||
this.isSessionMode = false;
|
||||
this._heartbeatTimer = null;
|
||||
|
||||
// Overlay timer
|
||||
this.overlayTimeout = null;
|
||||
@@ -243,20 +238,6 @@ class WatchPage {
|
||||
this.scrollHint?.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// On page close/refresh: stop heartbeat so the 3-min idle timeout kicks in fast
|
||||
window.addEventListener('beforeunload', () => {
|
||||
this._stopHeartbeat();
|
||||
});
|
||||
|
||||
// Also stop session when the page becomes hidden (tab switch, minimize)
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
this._stopHeartbeat();
|
||||
} else if (this.currentSessionId) {
|
||||
this._startHeartbeat(this.currentSessionId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -272,9 +253,6 @@ class WatchPage {
|
||||
this.currentEpisode = content.currentEpisode || null;
|
||||
this.resumeTime = content.resumeTime || 0;
|
||||
this.containerExtension = content.containerExtension || 'mp4';
|
||||
this.knownDurationSec = this.parseDurationSeconds(content.durationSec || content.duration || content.runtime || '');
|
||||
this.playbackBaseOffsetSec = 0;
|
||||
this.isSessionMode = false;
|
||||
this.returnPage = content.type === 'movie' ? 'movies' : 'series';
|
||||
|
||||
// Stop any Live TV playback before starting movie/series
|
||||
@@ -293,14 +271,6 @@ class WatchPage {
|
||||
// Update title bar
|
||||
this.titleEl.textContent = content.title || '';
|
||||
this.subtitleEl.textContent = content.subtitle || '';
|
||||
if (this.timeTotal) {
|
||||
this.timeTotal.textContent = this.knownDurationSec > 0 ? this.formatTime(this.knownDurationSec) : '0:00';
|
||||
}
|
||||
|
||||
// If duration is missing, fetch it from vod_info in the background
|
||||
if (this.knownDurationSec === 0 && content.sourceId && (content.vodId || content.id)) {
|
||||
this._fetchVodDuration(content.sourceId, content.vodId || content.id);
|
||||
}
|
||||
|
||||
// Load video
|
||||
await this.loadVideo(streamUrl);
|
||||
@@ -338,7 +308,7 @@ class WatchPage {
|
||||
const indicator = document.getElementById('now-playing-indicator');
|
||||
const textEl = document.getElementById('now-playing-text');
|
||||
if (indicator && textEl) {
|
||||
textEl.textContent = title || 'Reproduciendo';
|
||||
textEl.textContent = title || 'Now Playing';
|
||||
indicator.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
@@ -371,22 +341,10 @@ class WatchPage {
|
||||
if (!res.ok) throw new Error('Failed to start session');
|
||||
const session = await res.json();
|
||||
this.currentSessionId = session.sessionId;
|
||||
this.isSessionMode = true;
|
||||
this._startHeartbeat(session.sessionId);
|
||||
|
||||
// Use server-probed duration if we don't already have one
|
||||
if (session.durationSec > 0 && this.knownDurationSec === 0) {
|
||||
this.knownDurationSec = session.durationSec;
|
||||
console.log(`[WatchPage] Duration from FFprobe: ${session.durationSec}s`);
|
||||
if (this.timeTotal) {
|
||||
this.timeTotal.textContent = this.formatTime(session.durationSec);
|
||||
}
|
||||
}
|
||||
|
||||
return session.playlistUrl;
|
||||
} catch (err) {
|
||||
console.error('[WatchPage] Session start failed:', err);
|
||||
this.isSessionMode = true; // Still session-like for duration purposes
|
||||
// Fallback to direct transcode if session fails
|
||||
return `/api/transcode?url=${encodeURIComponent(url)}`;
|
||||
}
|
||||
}
|
||||
@@ -395,21 +353,15 @@ class WatchPage {
|
||||
* Stop and cleanup current transcode session
|
||||
*/
|
||||
async stopTranscodeSession() {
|
||||
this._stopHeartbeat();
|
||||
if (this.currentSessionId) {
|
||||
console.log('[WatchPage] Stopping transcode session:', this.currentSessionId);
|
||||
const sid = this.currentSessionId;
|
||||
this.currentSessionId = null;
|
||||
try {
|
||||
// sendBeacon works even when the page is being unloaded
|
||||
if (navigator.sendBeacon) {
|
||||
navigator.sendBeacon(`/api/transcode/${sid}/stop`, '{}');
|
||||
}
|
||||
// Also DELETE (for normal navigation)
|
||||
fetch(`/api/transcode/${sid}`, { method: 'DELETE' }).catch(() => {});
|
||||
// Fire and forget cleanup
|
||||
fetch(`/api/transcode/${this.currentSessionId}`, { method: 'DELETE' });
|
||||
} catch (err) {
|
||||
console.error('Failed to stop session:', err);
|
||||
}
|
||||
this.currentSessionId = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -460,7 +412,6 @@ class WatchPage {
|
||||
async loadVideo(url) {
|
||||
// Store the URL for copy functionality
|
||||
this.currentUrl = url;
|
||||
this.sourceStreamUrl = url;
|
||||
|
||||
// Stop any existing playback
|
||||
this.stop();
|
||||
@@ -468,23 +419,6 @@ class WatchPage {
|
||||
// Show loading spinner
|
||||
this.showLoading();
|
||||
|
||||
if (typeof window.kiratvPlayVodInNativePlayer === 'function' && window.kiratvDesktop?.isDesktopApp) {
|
||||
this.hideLoading();
|
||||
const absUrl = window.kiratvAbsolutePlaybackUrl(url);
|
||||
try {
|
||||
const result = await window.kiratvPlayVodInNativePlayer(absUrl);
|
||||
if (!result?.ok) {
|
||||
console.error('[KiraTV Desktop] VOD mpv:', result?.error);
|
||||
this.updateTranscodeStatus('hidden');
|
||||
} else {
|
||||
this.updateTranscodeStatus('direct', 'mpv nativo');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[KiraTV Desktop] VOD', e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Get settings for proxy/transcode
|
||||
let settings = {};
|
||||
try {
|
||||
@@ -528,7 +462,6 @@ class WatchPage {
|
||||
audioCodec: info.audio,
|
||||
audioChannels: info.audioChannels
|
||||
});
|
||||
this.playbackBaseOffsetSec = this.resumeTime || 0;
|
||||
this.playHls(playlistUrl);
|
||||
this.setVolumeFromStorage();
|
||||
return;
|
||||
@@ -548,30 +481,8 @@ class WatchPage {
|
||||
// Compatible - fall through to normal playback
|
||||
console.log('[WatchPage] Auto: Using normal playback (compatible)');
|
||||
} catch (err) {
|
||||
console.warn('[WatchPage] Probe failed:', err.message);
|
||||
const vodLike =
|
||||
isDirectVideo ||
|
||||
url.includes('/movie/') ||
|
||||
url.includes('/series/');
|
||||
if (vodLike) {
|
||||
console.warn(
|
||||
'[WatchPage] Auto: VOD-like URL after probe failure — HLS encode fallback (evita reproducir remux Jellyfin sin transcodificar)'
|
||||
);
|
||||
this.updateTranscodeStatus('transcoding', 'Transcoding (auto, probe fallback)');
|
||||
try {
|
||||
const playlistUrl = await this.startTranscodeSession(url, {
|
||||
videoMode: 'encode',
|
||||
seekOffset: this.resumeTime,
|
||||
});
|
||||
this.playbackBaseOffsetSec = this.resumeTime || 0;
|
||||
this.playHls(playlistUrl);
|
||||
this.setVolumeFromStorage();
|
||||
return;
|
||||
} catch (fallbackErr) {
|
||||
console.error('[WatchPage] Encode fallback failed:', fallbackErr);
|
||||
}
|
||||
}
|
||||
// Live u otro contenido: seguir reproducción directa habitual
|
||||
console.warn('[WatchPage] Probe failed, using normal playback:', err.message);
|
||||
// Continue with normal playback on probe failure
|
||||
}
|
||||
}
|
||||
|
||||
@@ -585,7 +496,6 @@ class WatchPage {
|
||||
videoMode: 'encode',
|
||||
seekOffset: this.resumeTime
|
||||
});
|
||||
this.playbackBaseOffsetSec = this.resumeTime || 0;
|
||||
this.playHls(playlistUrl);
|
||||
this.setVolumeFromStorage();
|
||||
return;
|
||||
@@ -609,7 +519,6 @@ class WatchPage {
|
||||
videoCodec,
|
||||
seekOffset: this.resumeTime
|
||||
});
|
||||
this.playbackBaseOffsetSec = this.resumeTime || 0;
|
||||
this.playHls(playlistUrl);
|
||||
this.setVolumeFromStorage();
|
||||
return;
|
||||
@@ -621,7 +530,6 @@ class WatchPage {
|
||||
this.updateTranscodeStatus('remuxing', 'Remux (Force)');
|
||||
const finalUrl = `/api/remux?url=${encodeURIComponent(url)}`;
|
||||
this.video.src = finalUrl;
|
||||
this.playbackBaseOffsetSec = 0;
|
||||
this.video.play().catch(e => {
|
||||
if (e.name !== 'AbortError') console.error('[WatchPage] Autoplay error:', e);
|
||||
});
|
||||
@@ -639,12 +547,10 @@ class WatchPage {
|
||||
// Use HLS.js for HLS streams
|
||||
if (looksLikeHls && Hls.isSupported()) {
|
||||
this.updateTranscodeStatus('direct', 'Direct HLS');
|
||||
this.playbackBaseOffsetSec = 0;
|
||||
this.playHls(finalUrl);
|
||||
} else {
|
||||
// Direct playback for mp4/mkv/avi
|
||||
this.updateTranscodeStatus('direct', 'Direct Play');
|
||||
this.playbackBaseOffsetSec = 0;
|
||||
this.video.src = finalUrl;
|
||||
this.video.play().catch(e => {
|
||||
if (e.name !== 'AbortError') console.error('[WatchPage] Autoplay error:', e);
|
||||
@@ -715,13 +621,6 @@ class WatchPage {
|
||||
this.stopHistoryTracking();
|
||||
this.saveProgress();
|
||||
|
||||
if (typeof window !== 'undefined' && window.kiratvDesktop?.stopExternalMpv) {
|
||||
window.kiratvDesktop.stopExternalMpv().catch(() => {});
|
||||
}
|
||||
if (typeof window.kiratvDesktopClearEmbeddedChrome === 'function') {
|
||||
window.kiratvDesktopClearEmbeddedChrome();
|
||||
}
|
||||
|
||||
// Cleanup transcode session if exists
|
||||
this.stopTranscodeSession();
|
||||
this.updateTranscodeStatus('hidden');
|
||||
@@ -738,12 +637,8 @@ class WatchPage {
|
||||
}
|
||||
if (this.video) {
|
||||
this.video.pause();
|
||||
// En modo escritorio el vídeo va a mpv: limpiar src dispararía un error
|
||||
// inofensivo pero confuso (MEDIA_ELEMENT_ERROR: Empty src attribute).
|
||||
if (!window.kiratvDesktop?.isDesktopApp) {
|
||||
this.video.src = '';
|
||||
this.video.load();
|
||||
}
|
||||
this.video.src = '';
|
||||
this.video.load();
|
||||
}
|
||||
|
||||
this.hideNowPlaying();
|
||||
@@ -760,38 +655,15 @@ class WatchPage {
|
||||
}
|
||||
|
||||
skip(seconds) {
|
||||
if (!this.video) return;
|
||||
const totalDuration = this.getTotalDuration();
|
||||
const currentAbsolute = this.getAbsoluteCurrentTime();
|
||||
if (totalDuration > 0) {
|
||||
const target = Math.max(0, Math.min(currentAbsolute + seconds, totalDuration));
|
||||
this.seekToAbsolute(target);
|
||||
} else {
|
||||
this.video.currentTime = Math.max(0, this.video.currentTime + seconds);
|
||||
if (this.video) {
|
||||
this.video.currentTime = Math.max(0, Math.min(this.video.currentTime + seconds, this.video.duration || 0));
|
||||
}
|
||||
}
|
||||
|
||||
seek(percent) {
|
||||
const totalDuration = this.getTotalDuration();
|
||||
if (!this.video || !totalDuration) return;
|
||||
const target = (percent / 100) * totalDuration;
|
||||
this.seekToAbsolute(target);
|
||||
}
|
||||
|
||||
seekToAbsolute(targetSec) {
|
||||
if (!this.video) return;
|
||||
|
||||
if (this.isSessionMode && this.sourceStreamUrl) {
|
||||
// Session-based HLS: the playlist only covers a window from the last seekOffset.
|
||||
// Re-create the session from the new target position.
|
||||
const offset = Math.max(0, Math.floor(targetSec));
|
||||
this.playbackBaseOffsetSec = offset;
|
||||
this.resumeTime = offset; // startTranscodeSession reads this for seekOffset
|
||||
this.loadVideo(this.sourceStreamUrl);
|
||||
return;
|
||||
if (this.video && this.video.duration) {
|
||||
this.video.currentTime = (percent / 100) * this.video.duration;
|
||||
}
|
||||
|
||||
this.video.currentTime = Math.max(0, targetSec);
|
||||
}
|
||||
|
||||
toggleMute() {
|
||||
@@ -896,22 +768,18 @@ class WatchPage {
|
||||
// === UI Updates ===
|
||||
|
||||
updateProgress() {
|
||||
if (!this.video) return;
|
||||
if (!this.video || !this.video.duration) return;
|
||||
|
||||
const totalDuration = this.getTotalDuration();
|
||||
if (!totalDuration) return;
|
||||
|
||||
const currentAbs = this.getAbsoluteCurrentTime();
|
||||
const percent = (currentAbs / totalDuration) * 100;
|
||||
this.progressSlider.value = Math.max(0, Math.min(100, percent));
|
||||
this.timeCurrent.textContent = this.formatTime(currentAbs);
|
||||
const percent = (this.video.currentTime / this.video.duration) * 100;
|
||||
this.progressSlider.value = percent;
|
||||
this.timeCurrent.textContent = this.formatTime(this.video.currentTime);
|
||||
|
||||
// Show "Up Next" panel early for series (like streaming services do during credits)
|
||||
// Only show if auto-play next episode is enabled
|
||||
const autoPlayEnabled = this.app?.player?.settings?.autoPlayNextEpisode;
|
||||
if (autoPlayEnabled && this.contentType === 'series' && this.seriesInfo && !this.nextEpisodeShowing && !this.nextEpisodeDismissed) {
|
||||
const duration = totalDuration;
|
||||
const currentTime = currentAbs;
|
||||
const duration = this.video.duration;
|
||||
const currentTime = this.video.currentTime;
|
||||
|
||||
// Only proceed if we have reliable duration data
|
||||
if (isFinite(duration) && duration >= 180 && currentTime >= 120) {
|
||||
@@ -939,10 +807,8 @@ class WatchPage {
|
||||
this.updateQualityBadge();
|
||||
}
|
||||
|
||||
// Handle resumption (only for direct video, NOT session mode).
|
||||
// In session mode FFmpeg already starts from the seekOffset, so
|
||||
// video.currentTime=0 already represents the correct absolute position.
|
||||
if (!this.isSessionMode && this.resumeTime > 0 && this.video) {
|
||||
// Handle resumption
|
||||
if (this.resumeTime > 0 && this.video) {
|
||||
const duration = this.video.duration;
|
||||
// Only resume if not near the end (95%)
|
||||
if (!duration || this.resumeTime < duration * 0.95) {
|
||||
@@ -950,14 +816,6 @@ class WatchPage {
|
||||
this.video.currentTime = this.resumeTime;
|
||||
}
|
||||
this.resumeTime = 0; // Reset after use
|
||||
} else if (this.isSessionMode) {
|
||||
this.resumeTime = 0; // Clear so it's not applied again
|
||||
}
|
||||
|
||||
// Update total duration label using known VOD runtime when browser reports Infinity/NaN
|
||||
const totalDuration = this.getTotalDuration();
|
||||
if (this.timeTotal && totalDuration > 0) {
|
||||
this.timeTotal.textContent = this.formatTime(totalDuration);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -994,11 +852,11 @@ class WatchPage {
|
||||
}
|
||||
|
||||
onError(e) {
|
||||
// Only log actual fatal errors, not benign stream recovery events
|
||||
const error = this.video?.error;
|
||||
if (!error || !error.code) return;
|
||||
// Ignorar error por src vacío (ocurre al limpiar el elemento tras parar)
|
||||
if (error.code === 4 && (!this.video.src || this.video.src === window.location.href)) return;
|
||||
console.error('[WatchPage] Video error:', error.code, error.message);
|
||||
if (error && error.code) {
|
||||
console.error('[WatchPage] Video error:', error.code, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
updateVolumeUI() {
|
||||
@@ -1018,90 +876,6 @@ class WatchPage {
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
parseDurationSeconds(value) {
|
||||
if (!value) return 0;
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return Math.max(0, Math.floor(value));
|
||||
const str = String(value).trim();
|
||||
if (!str) return 0;
|
||||
|
||||
// HH:MM:SS or MM:SS (allow any number of digits in the first segment)
|
||||
if (/^\d+:\d{1,2}(:\d{1,2})?$/.test(str)) {
|
||||
const parts = str.split(':').map(n => parseInt(n, 10));
|
||||
if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||||
if (parts.length === 2) return parts[0] * 60 + parts[1];
|
||||
}
|
||||
|
||||
// Plain seconds string
|
||||
const asNum = parseInt(str, 10);
|
||||
return Number.isFinite(asNum) ? Math.max(0, asNum) : 0;
|
||||
}
|
||||
|
||||
_startHeartbeat(sessionId) {
|
||||
this._stopHeartbeat();
|
||||
// Ping every 90 seconds — well within the 3-minute idle timeout on the worker
|
||||
this._heartbeatTimer = setInterval(async () => {
|
||||
if (!sessionId) return;
|
||||
try {
|
||||
await fetch(`/api/transcode/${sessionId}/heartbeat`, { method: 'POST' });
|
||||
} catch (_) { /* ignore */ }
|
||||
}, 90_000);
|
||||
}
|
||||
|
||||
_stopHeartbeat() {
|
||||
if (this._heartbeatTimer) {
|
||||
clearInterval(this._heartbeatTimer);
|
||||
this._heartbeatTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch VOD/episode duration from the Xtream vod_info endpoint in the background.
|
||||
* Updates knownDurationSec and the UI time label once the response arrives.
|
||||
*/
|
||||
async _fetchVodDuration(sourceId, vodId) {
|
||||
try {
|
||||
const res = await fetch(`/api/proxy/xtream/${sourceId}/vod_info?vod_id=${vodId}`);
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const raw = data?.info?.duration_secs
|
||||
|| data?.info?.duration
|
||||
|| data?.movie_data?.duration_secs
|
||||
|| data?.info?.runtime;
|
||||
const secs = this.parseDurationSeconds(raw);
|
||||
if (secs > 0 && secs > this.knownDurationSec) {
|
||||
console.log(`[WatchPage] VOD duration from vod_info: ${secs}s`);
|
||||
this.knownDurationSec = secs;
|
||||
if (this.timeTotal) {
|
||||
this.timeTotal.textContent = this.formatTime(secs);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Non-critical — silently ignore
|
||||
}
|
||||
}
|
||||
|
||||
getTotalDuration() {
|
||||
// In session mode (HLS generated by our worker), video.duration only
|
||||
// reflects the segments generated so far, NOT the full movie length.
|
||||
// Always prefer the known VOD duration in this case.
|
||||
if (this.isSessionMode && this.knownDurationSec > 0) {
|
||||
return this.knownDurationSec;
|
||||
}
|
||||
|
||||
const mediaDuration = this.video?.duration;
|
||||
if (Number.isFinite(mediaDuration) && mediaDuration > 0) {
|
||||
const total = mediaDuration + (this.playbackBaseOffsetSec || 0);
|
||||
// Even outside session mode, pick whichever is larger
|
||||
return (this.knownDurationSec > total) ? this.knownDurationSec : total;
|
||||
}
|
||||
return this.knownDurationSec || 0;
|
||||
}
|
||||
|
||||
getAbsoluteCurrentTime() {
|
||||
if (!this.video) return 0;
|
||||
return (this.playbackBaseOffsetSec || 0) + (this.video.currentTime || 0);
|
||||
}
|
||||
|
||||
// === Loading Spinner ===
|
||||
|
||||
showLoading() {
|
||||
@@ -1643,7 +1417,7 @@ class WatchPage {
|
||||
try {
|
||||
const data = {
|
||||
title: this.content.title || 'Unknown Title',
|
||||
subtitle: this.content.subtitle || (this.content.type === 'movie' ? 'Película' : 'Serie'),
|
||||
subtitle: this.content.subtitle || (this.content.type === 'movie' ? 'Movie' : 'Series'),
|
||||
poster: this.content.poster,
|
||||
sourceId: this.content.sourceId,
|
||||
containerExtension: this.containerExtension,
|
||||
|
||||
+2
-1
@@ -67,7 +67,8 @@ function getDefaultSettings() {
|
||||
forceVideoTranscode: false, // Force Video Transcode
|
||||
forceRemux: false,
|
||||
autoTranscode: true,
|
||||
streamFormat: 'm3u8',
|
||||
// Dispatcharr (y muchos Xtream) sirven MPEG-TS aunque la extensión sea .m3u8; Hls.js falla. Usar ts.
|
||||
streamFormat: 'ts',
|
||||
epgRefreshInterval: '24',
|
||||
// User-Agent settings
|
||||
userAgentPreset: 'chrome', // chrome | vlc | tivimate | custom
|
||||
|
||||
+66
-24
@@ -1,6 +1,14 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getDb } = require('../db/sqlite');
|
||||
const { sources } = require('../db');
|
||||
const { requireAuth } = require('../auth');
|
||||
const {
|
||||
getAccessibleSourceIds,
|
||||
assertCanUseSourceById
|
||||
} = require('../sourceAccess');
|
||||
|
||||
router.use(requireAuth);
|
||||
|
||||
// Helper to map API item types to DB types and tables
|
||||
function mapItemType(apiType) {
|
||||
@@ -21,27 +29,44 @@ router.get('/hidden', async (req, res) => {
|
||||
const { sourceId } = req.query;
|
||||
const db = getDb();
|
||||
|
||||
let hidden = [];
|
||||
let allowedIds = null;
|
||||
if (req.user.role !== 'admin') {
|
||||
allowedIds = await getAccessibleSourceIds(req, sources);
|
||||
if (allowedIds.length === 0) {
|
||||
return res.json([]);
|
||||
}
|
||||
}
|
||||
|
||||
const resultFormat = (row, itemType) => ({
|
||||
source_id: row.source_id,
|
||||
item_type: itemType,
|
||||
item_id: itemType.includes('category') || itemType === 'group' ? row.category_id : row.item_id
|
||||
});
|
||||
|
||||
// Query Categories
|
||||
let catQuery = `SELECT source_id, category_id, type FROM categories WHERE is_hidden = 1`;
|
||||
let itemQuery = `SELECT source_id, item_id, type FROM playlist_items WHERE is_hidden = 1`;
|
||||
let catParams = [];
|
||||
let itemParams = [];
|
||||
|
||||
const params = [];
|
||||
if (sourceId) {
|
||||
if (!(await assertCanUseSourceById(req, res, sources, sourceId))) return;
|
||||
const sid = parseInt(sourceId, 10);
|
||||
catQuery += ` AND source_id = ?`;
|
||||
itemQuery += ` AND source_id = ?`;
|
||||
const sid = parseInt(sourceId);
|
||||
params.push(sid);
|
||||
catParams.push(sid);
|
||||
itemParams.push(sid);
|
||||
} else if (allowedIds) {
|
||||
const ph = allowedIds.map(() => '?').join(',');
|
||||
catQuery += ` AND source_id IN (${ph})`;
|
||||
itemQuery += ` AND source_id IN (${ph})`;
|
||||
catParams = [...allowedIds];
|
||||
itemParams = [...allowedIds];
|
||||
}
|
||||
|
||||
const hiddenCats = db.prepare(catQuery).all(...params);
|
||||
const hiddenItems = db.prepare(itemQuery).all(...params);
|
||||
const hiddenCats = db.prepare(catQuery).all(...catParams);
|
||||
const hiddenItems = db.prepare(itemQuery).all(...itemParams);
|
||||
|
||||
const hidden = [];
|
||||
|
||||
hiddenCats.forEach(row => {
|
||||
let apiType;
|
||||
@@ -75,6 +100,7 @@ router.post('/hide', async (req, res) => {
|
||||
const mapping = mapItemType(itemType);
|
||||
|
||||
if (!mapping) return res.status(400).json({ error: 'Invalid item type' });
|
||||
if (!(await assertCanUseSourceById(req, res, sources, sourceId))) return;
|
||||
|
||||
const db = getDb();
|
||||
const idCol = mapping.table === 'categories' ? 'category_id' : 'item_id';
|
||||
@@ -101,6 +127,7 @@ router.post('/show', async (req, res) => {
|
||||
const mapping = mapItemType(itemType);
|
||||
|
||||
if (!mapping) return res.status(400).json({ error: 'Invalid item type' });
|
||||
if (!(await assertCanUseSourceById(req, res, sources, sourceId))) return;
|
||||
|
||||
const db = getDb();
|
||||
const idCol = mapping.table === 'categories' ? 'category_id' : 'item_id';
|
||||
@@ -127,6 +154,8 @@ router.get('/hidden/check', async (req, res) => {
|
||||
const mapping = mapItemType(itemType);
|
||||
if (!mapping) return res.json({ hidden: false });
|
||||
|
||||
if (!(await assertCanUseSourceById(req, res, sources, sourceId))) return;
|
||||
|
||||
const db = getDb();
|
||||
const idCol = mapping.table === 'categories' ? 'category_id' : 'item_id';
|
||||
|
||||
@@ -148,13 +177,16 @@ router.post('/hide/bulk', async (req, res) => {
|
||||
const { items } = req.body;
|
||||
if (!Array.isArray(items)) return res.status(400).json({ error: 'items array required' });
|
||||
|
||||
const uniqueIds = [...new Set(items.map(i => i.sourceId))];
|
||||
for (const sid of uniqueIds) {
|
||||
if (!(await assertCanUseSourceById(req, res, sources, sid))) return;
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// Prepare statements once
|
||||
const hideCat = db.prepare('UPDATE categories SET is_hidden = 1 WHERE source_id = ? AND type = ? AND category_id = ?');
|
||||
const hideItem = db.prepare('UPDATE playlist_items SET is_hidden = 1 WHERE source_id = ? AND type = ? AND item_id = ?');
|
||||
|
||||
// Cascading statements (hide all children of a category)
|
||||
const hideCatChildren = db.prepare('UPDATE playlist_items SET is_hidden = 1 WHERE source_id = ? AND type = ? AND category_id = ?');
|
||||
|
||||
const runBulk = db.transaction((list) => {
|
||||
@@ -162,12 +194,9 @@ router.post('/hide/bulk', async (req, res) => {
|
||||
const mapping = mapItemType(item.itemType);
|
||||
if (mapping) {
|
||||
if (mapping.table === 'categories') {
|
||||
// Hide the category
|
||||
hideCat.run(item.sourceId, mapping.type, item.itemId);
|
||||
// Cascade to children
|
||||
hideCatChildren.run(item.sourceId, mapping.type, item.itemId);
|
||||
} else {
|
||||
// Hide individual item
|
||||
hideItem.run(item.sourceId, mapping.type, item.itemId);
|
||||
}
|
||||
}
|
||||
@@ -191,13 +220,16 @@ router.post('/show/bulk', async (req, res) => {
|
||||
const { items } = req.body;
|
||||
if (!Array.isArray(items)) return res.status(400).json({ error: 'items array required' });
|
||||
|
||||
const uniqueIds = [...new Set(items.map(i => i.sourceId))];
|
||||
for (const sid of uniqueIds) {
|
||||
if (!(await assertCanUseSourceById(req, res, sources, sid))) return;
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// Prepare statements once
|
||||
const showCat = db.prepare('UPDATE categories SET is_hidden = 0 WHERE source_id = ? AND type = ? AND category_id = ?');
|
||||
const showItem = db.prepare('UPDATE playlist_items SET is_hidden = 0 WHERE source_id = ? AND type = ? AND item_id = ?');
|
||||
|
||||
// Cascading statements (show all children of a category)
|
||||
const showCatChildren = db.prepare('UPDATE playlist_items SET is_hidden = 0 WHERE source_id = ? AND type = ? AND category_id = ?');
|
||||
|
||||
const runBulk = db.transaction((list) => {
|
||||
@@ -205,12 +237,9 @@ router.post('/show/bulk', async (req, res) => {
|
||||
const mapping = mapItemType(item.itemType);
|
||||
if (mapping) {
|
||||
if (mapping.table === 'categories') {
|
||||
// Show the category
|
||||
showCat.run(item.sourceId, mapping.type, item.itemId);
|
||||
// Cascade to children
|
||||
showCatChildren.run(item.sourceId, mapping.type, item.itemId);
|
||||
} else {
|
||||
// Show individual item
|
||||
showItem.run(item.sourceId, mapping.type, item.itemId);
|
||||
}
|
||||
}
|
||||
@@ -234,14 +263,15 @@ router.post('/show/all', async (req, res) => {
|
||||
const { sourceId, contentType } = req.body;
|
||||
if (!sourceId) return res.status(400).json({ error: 'sourceId required' });
|
||||
|
||||
if (!(await assertCanUseSourceById(req, res, sources, sourceId))) return;
|
||||
|
||||
const db = getDb();
|
||||
let catCount = 0;
|
||||
let itemCount = 0;
|
||||
|
||||
// Determine which types to update based on contentType
|
||||
const types = contentType === 'movies' ? ['movie']
|
||||
: contentType === 'series' ? ['series']
|
||||
: ['live']; // default to channels
|
||||
: ['live'];
|
||||
|
||||
for (const type of types) {
|
||||
const catResult = db.prepare(`UPDATE categories SET is_hidden = 0 WHERE source_id = ? AND type = ?`).run(sourceId, type);
|
||||
@@ -264,14 +294,15 @@ router.post('/hide/all', async (req, res) => {
|
||||
const { sourceId, contentType } = req.body;
|
||||
if (!sourceId) return res.status(400).json({ error: 'sourceId required' });
|
||||
|
||||
if (!(await assertCanUseSourceById(req, res, sources, sourceId))) return;
|
||||
|
||||
const db = getDb();
|
||||
let catCount = 0;
|
||||
let itemCount = 0;
|
||||
|
||||
// Determine which types to update based on contentType
|
||||
const types = contentType === 'movies' ? ['movie']
|
||||
: contentType === 'series' ? ['series']
|
||||
: ['live']; // default to channels
|
||||
: ['live'];
|
||||
|
||||
for (const type of types) {
|
||||
const catResult = db.prepare(`UPDATE categories SET is_hidden = 1 WHERE source_id = ? AND type = ?`).run(sourceId, type);
|
||||
@@ -296,6 +327,18 @@ router.get('/recent', async (req, res) => {
|
||||
return res.status(400).json({ error: 'Valid type (movie or series) is required' });
|
||||
}
|
||||
|
||||
let accessibleFilter = '';
|
||||
const params = [type];
|
||||
if (req.user.role !== 'admin') {
|
||||
const ids = await getAccessibleSourceIds(req, sources);
|
||||
if (ids.length === 0) {
|
||||
return res.json([]);
|
||||
}
|
||||
accessibleFilter = ` AND p.source_id IN (${ids.map(() => '?').join(',')})`;
|
||||
params.push(...ids);
|
||||
}
|
||||
params.push(parseInt(limit, 10));
|
||||
|
||||
const db = getDb();
|
||||
const recentItems = db.prepare(`
|
||||
SELECT * FROM playlist_items p
|
||||
@@ -308,11 +351,11 @@ router.get('/recent', async (req, res) => {
|
||||
AND c.type = p.type
|
||||
AND c.is_hidden = 1
|
||||
)
|
||||
${accessibleFilter}
|
||||
ORDER BY p.added_at DESC
|
||||
LIMIT ?
|
||||
`).all(type, parseInt(limit));
|
||||
`).all(...params);
|
||||
|
||||
// Parse JSON data for each item
|
||||
const formatted = recentItems.map(item => ({
|
||||
...item,
|
||||
data: JSON.parse(item.data)
|
||||
@@ -326,4 +369,3 @@ router.get('/recent', async (req, res) => {
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
|
||||
+5
-17
@@ -1,7 +1,6 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { spawn } = require('child_process');
|
||||
const streamInput = require('../utils/streamInput');
|
||||
|
||||
/**
|
||||
* Probe endpoint - detects stream codecs and container
|
||||
@@ -31,27 +30,16 @@ const BROWSER_AUDIO_CODECS = ['aac', 'mp3', 'opus', 'vorbis'];
|
||||
*/
|
||||
function probeStream(url, ffprobePath, userAgent = null, timeout = 15000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const sizing = streamInput.probeSizingForUrl(url);
|
||||
const referer = streamInput.deriveRefererFromUrl(url);
|
||||
const effectiveTimeout = sizing.heavy ? Math.max(timeout, 45000) : timeout;
|
||||
|
||||
const args = [
|
||||
'-v', 'error',
|
||||
'-user_agent', userAgent || 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36'
|
||||
];
|
||||
|
||||
if (referer) {
|
||||
args.push('-referer', referer);
|
||||
}
|
||||
|
||||
args.push(
|
||||
'-user_agent', userAgent || 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
|
||||
'-print_format', 'json',
|
||||
'-show_streams',
|
||||
'-show_format',
|
||||
'-probesize', sizing.probesize,
|
||||
'-analyzeduration', sizing.analyzeduration,
|
||||
'-probesize', '5000000',
|
||||
'-analyzeduration', '5000000',
|
||||
url
|
||||
);
|
||||
];
|
||||
|
||||
const proc = spawn(ffprobePath, args);
|
||||
let stdout = '';
|
||||
@@ -60,7 +48,7 @@ function probeStream(url, ffprobePath, userAgent = null, timeout = 15000) {
|
||||
const timer = setTimeout(() => {
|
||||
proc.kill('SIGKILL');
|
||||
reject(new Error('Probe timeout'));
|
||||
}, effectiveTimeout);
|
||||
}, timeout);
|
||||
|
||||
proc.stdout.on('data', (data) => { stdout += data; });
|
||||
proc.stderr.on('data', (data) => { stderr += data; });
|
||||
|
||||
+45
-18
@@ -12,6 +12,35 @@ const https = require('https');
|
||||
const { spawn } = require('child_process');
|
||||
const ffmpegPath = require('ffmpeg-static');
|
||||
const { Readable } = require('stream');
|
||||
const auth = require('../auth');
|
||||
const { canUserAccessSource } = require('../sourceAccess');
|
||||
|
||||
// Rutas que el navegador pide sin Bearer (HLS, <img>, etc.) deben quedar fuera de JWT
|
||||
router.use((req, res, next) => {
|
||||
const p = req.path || (req.url || '').split('?')[0];
|
||||
if (p === '/stream' || p === '/image') return next();
|
||||
return auth.requireAuth(req, res, next);
|
||||
});
|
||||
|
||||
router.param('sourceId', async (req, res, next, id) => {
|
||||
try {
|
||||
const sid = parseInt(id, 10);
|
||||
if (Number.isNaN(sid)) {
|
||||
return res.status(400).json({ error: 'Invalid source id' });
|
||||
}
|
||||
const source = await sources.getById(sid);
|
||||
if (!source) {
|
||||
return res.status(404).json({ error: 'Source not found' });
|
||||
}
|
||||
if (!canUserAccessSource(req.user, source)) {
|
||||
return res.status(403).json({ error: 'No access to this source' });
|
||||
}
|
||||
req.grantedSource = source;
|
||||
next();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// Default cache max age in hours
|
||||
const DEFAULT_MAX_AGE_HOURS = 24;
|
||||
@@ -83,8 +112,8 @@ function getStreamsFromDb(sourceId, type, categoryId = null, includeHidden = fal
|
||||
// Login / Authenticate
|
||||
router.get('/xtream/:sourceId', async (req, res) => {
|
||||
try {
|
||||
const source = await sources.getById(req.params.sourceId);
|
||||
if (!source || source.type !== 'xtream') return res.status(404).send('Source not found');
|
||||
const source = req.grantedSource;
|
||||
if (source.type !== 'xtream') return res.status(404).send('Source not found');
|
||||
|
||||
// Proxy auth check to upstream to ensure credentials are still valid
|
||||
|
||||
@@ -185,8 +214,7 @@ router.get('/xtream/:sourceId/series', async (req, res) => {
|
||||
// Proxy series info request
|
||||
router.get('/xtream/:sourceId/series_info', async (req, res) => {
|
||||
try {
|
||||
const source = await sources.getById(req.params.sourceId);
|
||||
if (!source) return res.status(404).send('Source not found');
|
||||
const source = req.grantedSource;
|
||||
|
||||
const seriesId = req.query.series_id;
|
||||
if (!seriesId) return res.status(400).send('series_id required');
|
||||
@@ -207,8 +235,7 @@ router.get('/xtream/:sourceId/series_info', async (req, res) => {
|
||||
// VOD Info
|
||||
router.get('/xtream/:sourceId/vod_info', async (req, res) => {
|
||||
try {
|
||||
const source = await sources.getById(req.params.sourceId);
|
||||
if (!source) return res.status(404).send('Source not found');
|
||||
const source = req.grantedSource;
|
||||
|
||||
const vodId = req.query.vod_id;
|
||||
if (!vodId) return res.status(400).send('vod_id required');
|
||||
@@ -230,14 +257,14 @@ router.get('/xtream/:sourceId/vod_info', async (req, res) => {
|
||||
// Returns the direct stream URL for a given stream ID
|
||||
router.get('/xtream/:sourceId/stream/:streamId/:type', async (req, res) => {
|
||||
try {
|
||||
const source = await sources.getById(req.params.sourceId);
|
||||
if (!source || source.type !== 'xtream') {
|
||||
const source = req.grantedSource;
|
||||
if (source.type !== 'xtream') {
|
||||
return res.status(404).json({ error: 'Xtream source not found' });
|
||||
}
|
||||
|
||||
const streamId = req.params.streamId;
|
||||
const type = req.params.type || 'live';
|
||||
const container = req.query.container || 'm3u8';
|
||||
const container = req.query.container || 'ts';
|
||||
|
||||
// Construct the Xtream stream URL
|
||||
// Format: http://server:port/live/username/password/streamId.container (for live)
|
||||
@@ -393,8 +420,8 @@ router.delete('/cache/:sourceId', (req, res) => {
|
||||
router.get('/xtream/:sourceId/:action', async (req, res) => {
|
||||
try {
|
||||
const sourceId = req.params.sourceId;
|
||||
const source = await sources.getById(sourceId);
|
||||
if (!source || source.type !== 'xtream') {
|
||||
const source = req.grantedSource;
|
||||
if (source.type !== 'xtream') {
|
||||
return res.status(404).json({ error: 'Xtream source not found' });
|
||||
}
|
||||
|
||||
@@ -478,14 +505,14 @@ router.get('/xtream/:sourceId/:action', async (req, res) => {
|
||||
*/
|
||||
router.get('/xtream/:sourceId/stream/:streamId/:type?', async (req, res) => {
|
||||
try {
|
||||
const source = await sources.getById(req.params.sourceId);
|
||||
if (!source || source.type !== 'xtream') {
|
||||
const source = req.grantedSource;
|
||||
if (source.type !== 'xtream') {
|
||||
return res.status(404).json({ error: 'Xtream source not found' });
|
||||
}
|
||||
|
||||
const api = xtreamApi.createFromSource(source);
|
||||
const { streamId, type = 'live' } = req.params;
|
||||
const { container = 'm3u8' } = req.query;
|
||||
const { container = 'ts' } = req.query;
|
||||
|
||||
const url = api.buildStreamUrl(streamId, type, container);
|
||||
res.json({ url });
|
||||
@@ -505,8 +532,8 @@ router.get('/xtream/:sourceId/stream/:streamId/:type?', async (req, res) => {
|
||||
router.get('/epg/:sourceId', async (req, res) => {
|
||||
try {
|
||||
const sourceId = req.params.sourceId;
|
||||
const source = await sources.getById(sourceId);
|
||||
if (!source || (source.type !== 'epg' && source.type !== 'xtream')) {
|
||||
const source = req.grantedSource;
|
||||
if (source.type !== 'epg' && source.type !== 'xtream') {
|
||||
return res.status(404).json({ error: 'Valid EPG source not found' });
|
||||
}
|
||||
|
||||
@@ -567,8 +594,8 @@ router.delete('/epg/:sourceId/cache', (req, res) => {
|
||||
*/
|
||||
router.post('/epg/:sourceId/channels', async (req, res) => {
|
||||
try {
|
||||
const source = await sources.getById(req.params.sourceId);
|
||||
if (!source || source.type !== 'epg') {
|
||||
const source = req.grantedSource;
|
||||
if (source.type !== 'epg') {
|
||||
return res.status(404).json({ error: 'EPG source not found' });
|
||||
}
|
||||
|
||||
|
||||
+137
-97
@@ -5,29 +5,84 @@ const { getDb } = require('../db/sqlite');
|
||||
const xtreamApi = require('../services/xtreamApi');
|
||||
const syncService = require('../services/syncService');
|
||||
const m3uParser = require('../services/m3uParser');
|
||||
const { requireAuth, requireAdmin } = require('../auth');
|
||||
const { canUserAccessSource, getAccessibleSourceIds } = require('../sourceAccess');
|
||||
|
||||
// Get all sources
|
||||
router.use(requireAuth);
|
||||
|
||||
function sanitizeSourceList(list) {
|
||||
return list.map((s) => ({
|
||||
...s,
|
||||
password: s.password ? '••••••••' : null
|
||||
}));
|
||||
}
|
||||
|
||||
function parseOwnerId(body) {
|
||||
if (body.ownerId === undefined || body.ownerId === '') return null;
|
||||
const n = parseInt(body.ownerId, 10);
|
||||
return Number.isNaN(n) ? null : n;
|
||||
}
|
||||
|
||||
// Estimate by URL (must stay before /:id routes)
|
||||
const M3U_LARGE_THRESHOLD = 50000;
|
||||
|
||||
router.post('/estimate', async (req, res) => {
|
||||
try {
|
||||
const { url, type } = req.body;
|
||||
|
||||
if (!url) {
|
||||
return res.status(400).json({ error: 'URL is required' });
|
||||
}
|
||||
|
||||
if (type !== 'm3u') {
|
||||
return res.json({ count: 0, needsWarning: false, threshold: M3U_LARGE_THRESHOLD });
|
||||
}
|
||||
|
||||
const count = await m3uParser.countEntries(url);
|
||||
|
||||
res.json({
|
||||
count,
|
||||
needsWarning: count > M3U_LARGE_THRESHOLD,
|
||||
threshold: M3U_LARGE_THRESHOLD
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error estimating M3U size:', err);
|
||||
res.status(500).json({ error: 'Failed to estimate playlist size', message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Global sync — admin only
|
||||
router.post('/sync-all', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
syncService.syncAll().catch(console.error);
|
||||
res.json({ success: true, message: 'Global sync started' });
|
||||
} catch (err) {
|
||||
console.error('Error starting global sync:', err);
|
||||
res.status(500).json({ error: 'Failed to start global sync' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get all sources (filtered by access)
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const allSources = await sources.getAll();
|
||||
// Don't expose passwords in list view
|
||||
const sanitized = allSources.map(s => ({
|
||||
...s,
|
||||
password: s.password ? '••••••••' : null
|
||||
}));
|
||||
res.json(sanitized);
|
||||
const filtered = allSources.filter((s) => canUserAccessSource(req.user, s));
|
||||
res.json(sanitizeSourceList(filtered));
|
||||
} catch (err) {
|
||||
console.error('Error getting sources:', err);
|
||||
res.status(500).json({ error: 'Failed to get sources' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get sync status for all sources
|
||||
// Get sync status (filtered for non-admins)
|
||||
router.get('/status', async (req, res) => {
|
||||
try {
|
||||
const { getDb } = require('../db/sqlite');
|
||||
const db = getDb();
|
||||
const statuses = db.prepare('SELECT * FROM sync_status').all();
|
||||
let statuses = db.prepare('SELECT * FROM sync_status').all();
|
||||
if (req.user.role !== 'admin') {
|
||||
const allowed = new Set(await getAccessibleSourceIds(req, sources));
|
||||
statuses = statuses.filter((row) => allowed.has(row.source_id));
|
||||
}
|
||||
res.json(statuses);
|
||||
} catch (err) {
|
||||
console.error('Error getting sync status:', err);
|
||||
@@ -39,13 +94,42 @@ router.get('/status', async (req, res) => {
|
||||
router.get('/type/:type', async (req, res) => {
|
||||
try {
|
||||
const typeSources = await sources.getByType(req.params.type);
|
||||
res.json(typeSources);
|
||||
const filtered = typeSources.filter((s) => canUserAccessSource(req.user, s));
|
||||
res.json(sanitizeSourceList(filtered));
|
||||
} catch (err) {
|
||||
console.error('Error getting sources by type:', err);
|
||||
res.status(500).json({ error: 'Failed to get sources' });
|
||||
}
|
||||
});
|
||||
|
||||
// Estimate by source ID (before bare GET /:id — same base path length)
|
||||
router.get('/:id/estimate', async (req, res) => {
|
||||
try {
|
||||
const source = await sources.getById(req.params.id);
|
||||
if (!source) {
|
||||
return res.status(404).json({ error: 'Source not found' });
|
||||
}
|
||||
if (!canUserAccessSource(req.user, source)) {
|
||||
return res.status(403).json({ error: 'No access to this source' });
|
||||
}
|
||||
|
||||
if (source.type !== 'm3u') {
|
||||
return res.json({ count: 0, needsWarning: false, threshold: M3U_LARGE_THRESHOLD });
|
||||
}
|
||||
|
||||
const count = await m3uParser.countEntries(source.url);
|
||||
|
||||
res.json({
|
||||
count,
|
||||
needsWarning: count > M3U_LARGE_THRESHOLD,
|
||||
threshold: M3U_LARGE_THRESHOLD
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error estimating M3U size:', err);
|
||||
res.status(500).json({ error: 'Failed to estimate playlist size', message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get single source
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
@@ -53,6 +137,9 @@ router.get('/:id', async (req, res) => {
|
||||
if (!source) {
|
||||
return res.status(404).json({ error: 'Source not found' });
|
||||
}
|
||||
if (!canUserAccessSource(req.user, source)) {
|
||||
return res.status(403).json({ error: 'No access to this source' });
|
||||
}
|
||||
res.json(source);
|
||||
} catch (err) {
|
||||
console.error('Error getting source:', err);
|
||||
@@ -73,8 +160,14 @@ router.post('/', async (req, res) => {
|
||||
return res.status(400).json({ error: 'Invalid source type' });
|
||||
}
|
||||
|
||||
const source = await sources.create({ type, name, url, username, password });
|
||||
// Trigger Sync
|
||||
let ownerId = null;
|
||||
if (req.user.role === 'admin') {
|
||||
ownerId = parseOwnerId(req.body);
|
||||
} else {
|
||||
ownerId = req.user.id;
|
||||
}
|
||||
|
||||
const source = await sources.create({ type, name, url, username, password, ownerId });
|
||||
syncService.syncSource(source.id).catch(console.error);
|
||||
res.status(201).json(source);
|
||||
} catch (err) {
|
||||
@@ -90,16 +183,23 @@ router.put('/:id', async (req, res) => {
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: 'Source not found' });
|
||||
}
|
||||
if (!canUserAccessSource(req.user, existing)) {
|
||||
return res.status(403).json({ error: 'No access to this source' });
|
||||
}
|
||||
|
||||
const { name, url, username, password } = req.body;
|
||||
const updated = await sources.update(req.params.id, {
|
||||
const updates = {
|
||||
name: name || existing.name,
|
||||
url: url || existing.url,
|
||||
username: username !== undefined ? username : existing.username,
|
||||
password: password !== undefined ? password : existing.password
|
||||
});
|
||||
// Trigger Sync (if critical fields changed? safely just trigger it)
|
||||
syncService.syncSource(parseInt(req.params.id)).catch(console.error);
|
||||
};
|
||||
if (req.user.role === 'admin' && req.body.ownerId !== undefined) {
|
||||
updates.ownerId = parseOwnerId(req.body);
|
||||
}
|
||||
|
||||
const updated = await sources.update(req.params.id, updates);
|
||||
syncService.syncSource(parseInt(req.params.id, 10)).catch(console.error);
|
||||
res.json(updated);
|
||||
} catch (err) {
|
||||
console.error('Error updating source:', err);
|
||||
@@ -110,13 +210,15 @@ router.put('/:id', async (req, res) => {
|
||||
// Delete source
|
||||
router.delete('/:id', async (req, res) => {
|
||||
try {
|
||||
const sourceId = parseInt(req.params.id);
|
||||
const sourceId = parseInt(req.params.id, 10);
|
||||
const existing = await sources.getById(sourceId);
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: 'Source not found' });
|
||||
}
|
||||
if (!canUserAccessSource(req.user, existing)) {
|
||||
return res.status(403).json({ error: 'No access to this source' });
|
||||
}
|
||||
|
||||
// Cascade delete: Clean up SQLite data for this source
|
||||
const db = getDb();
|
||||
const deleteCategories = db.prepare('DELETE FROM categories WHERE source_id = ?');
|
||||
const deleteItems = db.prepare('DELETE FROM playlist_items WHERE source_id = ?');
|
||||
@@ -130,7 +232,6 @@ router.delete('/:id', async (req, res) => {
|
||||
|
||||
console.log(`[Source] Cascade delete for source ${sourceId}: ${catResult.changes} categories, ${itemResult.changes} items, ${epgResult.changes} EPG programs`);
|
||||
|
||||
// Delete source config and related hidden items (favorites handled by db.js)
|
||||
await sources.delete(sourceId);
|
||||
|
||||
res.json({ success: true });
|
||||
@@ -143,14 +244,21 @@ router.delete('/:id', async (req, res) => {
|
||||
// Toggle source enabled/disabled
|
||||
router.post('/:id/toggle', async (req, res) => {
|
||||
try {
|
||||
const existing = await sources.getById(req.params.id);
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: 'Source not found' });
|
||||
}
|
||||
if (!canUserAccessSource(req.user, existing)) {
|
||||
return res.status(403).json({ error: 'No access to this source' });
|
||||
}
|
||||
|
||||
const updated = await sources.toggleEnabled(req.params.id);
|
||||
if (!updated) {
|
||||
return res.status(404).json({ error: 'Source not found' });
|
||||
}
|
||||
|
||||
// If enabled, trigger sync
|
||||
if (updated.enabled) {
|
||||
syncService.syncSource(parseInt(req.params.id)).catch(console.error);
|
||||
syncService.syncSource(parseInt(req.params.id, 10)).catch(console.error);
|
||||
}
|
||||
|
||||
res.json(updated);
|
||||
@@ -163,11 +271,13 @@ router.post('/:id/toggle', async (req, res) => {
|
||||
// Manual Sync
|
||||
router.post('/:id/sync', async (req, res) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
const id = parseInt(req.params.id, 10);
|
||||
const source = await sources.getById(id);
|
||||
if (!source) return res.status(404).json({ error: 'Source not found' });
|
||||
if (!canUserAccessSource(req.user, source)) {
|
||||
return res.status(403).json({ error: 'No access to this source' });
|
||||
}
|
||||
|
||||
// Trigger sync (async)
|
||||
syncService.syncSource(id).catch(console.error);
|
||||
|
||||
res.json({ success: true, message: 'Sync started' });
|
||||
@@ -184,6 +294,9 @@ router.post('/:id/test', async (req, res) => {
|
||||
if (!source) {
|
||||
return res.status(404).json({ error: 'Source not found' });
|
||||
}
|
||||
if (!canUserAccessSource(req.user, source)) {
|
||||
return res.status(403).json({ error: 'No access to this source' });
|
||||
}
|
||||
|
||||
if (source.type === 'xtream') {
|
||||
const result = await xtreamApi.authenticate(source.url, source.username, source.password);
|
||||
@@ -205,77 +318,4 @@ router.post('/:id/test', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Estimate M3U playlist size (for large playlist warning)
|
||||
const M3U_LARGE_THRESHOLD = 50000;
|
||||
|
||||
// Estimate by URL (for new sources before creation)
|
||||
router.post('/estimate', async (req, res) => {
|
||||
try {
|
||||
const { url, type } = req.body;
|
||||
|
||||
if (!url) {
|
||||
return res.status(400).json({ error: 'URL is required' });
|
||||
}
|
||||
|
||||
// Only M3U sources need estimation
|
||||
if (type !== 'm3u') {
|
||||
return res.json({ count: 0, needsWarning: false, threshold: M3U_LARGE_THRESHOLD });
|
||||
}
|
||||
|
||||
console.log(`[Sources] Estimating M3U size for URL...`);
|
||||
const count = await m3uParser.countEntries(url);
|
||||
console.log(`[Sources] M3U estimate: ${count} entries`);
|
||||
|
||||
res.json({
|
||||
count,
|
||||
needsWarning: count > M3U_LARGE_THRESHOLD,
|
||||
threshold: M3U_LARGE_THRESHOLD
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error estimating M3U size:', err);
|
||||
res.status(500).json({ error: 'Failed to estimate playlist size', message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Estimate by source ID (for existing sources)
|
||||
router.get('/:id/estimate', async (req, res) => {
|
||||
try {
|
||||
const source = await sources.getById(req.params.id);
|
||||
if (!source) {
|
||||
return res.status(404).json({ error: 'Source not found' });
|
||||
}
|
||||
|
||||
// Only M3U sources need estimation
|
||||
if (source.type !== 'm3u') {
|
||||
return res.json({ count: 0, needsWarning: false, threshold: M3U_LARGE_THRESHOLD });
|
||||
}
|
||||
|
||||
console.log(`[Sources] Estimating M3U size for ${source.name}...`);
|
||||
const count = await m3uParser.countEntries(source.url);
|
||||
console.log(`[Sources] M3U estimate: ${count} entries`);
|
||||
|
||||
res.json({
|
||||
count,
|
||||
needsWarning: count > M3U_LARGE_THRESHOLD,
|
||||
threshold: M3U_LARGE_THRESHOLD
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error estimating M3U size:', err);
|
||||
res.status(500).json({ error: 'Failed to estimate playlist size', message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Global Sync - sync all enabled sources
|
||||
router.post('/sync-all', async (req, res) => {
|
||||
try {
|
||||
// Trigger global sync (async - don't wait for completion)
|
||||
syncService.syncAll().catch(console.error);
|
||||
res.json({ success: true, message: 'Global sync started' });
|
||||
} catch (err) {
|
||||
console.error('Error starting global sync:', err);
|
||||
res.status(500).json({ error: 'Failed to start global sync' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
Reference in New Issue
Block a user