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 |
@@ -2,11 +2,6 @@
|
|||||||
<img src="public/img/logo-banner.png" alt="nodecast-tv" height="60" />
|
<img src="public/img/logo-banner.png" alt="nodecast-tv" height="60" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
La aplicacion se ha modificado y personalizado.
|
|
||||||
Esta app por defecto tenía problemas a la hora de desconectarse de un canal y conectarse a otro. Se han añadido comandos para que cuando desconectamos un canal haga un "delete" y así envitar en medida de lo posible conexiones múltiples y zombies.
|
|
||||||
|
|
||||||
Se han añadido 3 Workers
|
|
||||||
|
|
||||||
# What is nodecast-tv?
|
# What is nodecast-tv?
|
||||||
|
|
||||||
nodecast-tv is a modern, web-based IPTV player featuring Live TV, EPG, Movies (VOD), and Series support. Built with performance and user experience in mind.
|
nodecast-tv is a modern, web-based IPTV player featuring Live TV, EPG, Movies (VOD), and Series support. Built with performance and user experience in mind.
|
||||||
|
|||||||
+10
-5
@@ -1,6 +1,11 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||||
<rect width="64" height="64" rx="14" fill="#000066"/>
|
<defs>
|
||||||
<path fill="#ffffff" d="M14 48V14h10l14 16V14h10v34H38L24 32v16H14z"/>
|
<linearGradient id="g" x1="0" y1="0" x2="64" y2="64">
|
||||||
<path stroke="#ffffff" stroke-width="2.5" stroke-linecap="round" fill="none" d="M36 20c8 0 14 6 14 14s-6 14-14 14"/>
|
<stop offset="0%" stop-color="#7c3aed"/>
|
||||||
<path stroke="#ffffff" stroke-width="2" stroke-linecap="round" fill="none" opacity=".9" d="M34 16c11 0 20 9 20 20s-9 20-20 20"/>
|
<stop offset="100%" stop-color="#4f46e5"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="64" height="64" rx="14" fill="url(#g)"/>
|
||||||
|
<rect x="10" y="18" width="44" height="28" rx="4" stroke="#fff" stroke-width="3" fill="none"/>
|
||||||
|
<path d="M22 32 L28 38 L42 24" stroke="#fff" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 447 B After Width: | Height: | Size: 553 B |
Binary file not shown.
|
Before Width: | Height: | Size: 72 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 52 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 75 KiB |
@@ -1104,7 +1104,6 @@
|
|||||||
<script src="/js/pages/SeriesPage.js?v=1"></script>
|
<script src="/js/pages/SeriesPage.js?v=1"></script>
|
||||||
<script src="/js/pages/Settings.js?v=3"></script>
|
<script src="/js/pages/Settings.js?v=3"></script>
|
||||||
<script src="/js/pages/WatchPage.js?v=3"></script>
|
<script src="/js/pages/WatchPage.js?v=3"></script>
|
||||||
<script src="/js/components/ChatBot.js?v=2"></script>
|
|
||||||
<script src="/js/app.js?v=5"></script>
|
<script src="/js/app.js?v=5"></script>
|
||||||
<script>
|
<script>
|
||||||
// Check for SSO token in URL
|
// Check for SSO token in URL
|
||||||
|
|||||||
@@ -1,428 +0,0 @@
|
|||||||
/**
|
|
||||||
* KiraBot — floating chat widget for KiraTV
|
|
||||||
*/
|
|
||||||
class ChatBot {
|
|
||||||
constructor() {
|
|
||||||
this._open = false;
|
|
||||||
this._tab = 'chat'; // 'chat' | 'recordings'
|
|
||||||
this._messages = [];
|
|
||||||
this._recordings = [];
|
|
||||||
this._loading = false;
|
|
||||||
this._token = null;
|
|
||||||
this._init();
|
|
||||||
}
|
|
||||||
|
|
||||||
_init() {
|
|
||||||
// Inject styles
|
|
||||||
const style = document.createElement('style');
|
|
||||||
style.textContent = `
|
|
||||||
#kirabot-btn {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 24px;
|
|
||||||
right: 24px;
|
|
||||||
z-index: 9000;
|
|
||||||
width: 56px;
|
|
||||||
height: 56px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: linear-gradient(135deg, #7c3aed, #4f46e5);
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
box-shadow: 0 4px 20px rgba(124,58,237,.5);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
transition: transform .2s, box-shadow .2s;
|
|
||||||
}
|
|
||||||
#kirabot-btn:hover { transform: scale(1.1); box-shadow: 0 6px 28px rgba(124,58,237,.7); }
|
|
||||||
#kirabot-btn svg { width: 26px; height: 26px; fill: #fff; }
|
|
||||||
#kirabot-badge {
|
|
||||||
position: absolute;
|
|
||||||
top: -4px;
|
|
||||||
right: -4px;
|
|
||||||
background: #ef4444;
|
|
||||||
color: #fff;
|
|
||||||
border-radius: 9999px;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 700;
|
|
||||||
min-width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
display: none;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0 4px;
|
|
||||||
}
|
|
||||||
#kirabot-panel {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 90px;
|
|
||||||
right: 24px;
|
|
||||||
z-index: 9001;
|
|
||||||
width: 380px;
|
|
||||||
max-width: calc(100vw - 32px);
|
|
||||||
height: 560px;
|
|
||||||
max-height: calc(100vh - 110px);
|
|
||||||
background: #1a1a2e;
|
|
||||||
border: 1px solid rgba(124,58,237,.4);
|
|
||||||
border-radius: 20px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
box-shadow: 0 16px 48px rgba(0,0,0,.6);
|
|
||||||
transition: opacity .25s, transform .25s;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
#kirabot-panel.hidden { opacity: 0; transform: translateY(12px) scale(.97); pointer-events: none; }
|
|
||||||
.kb-header {
|
|
||||||
background: linear-gradient(135deg, #7c3aed, #4f46e5);
|
|
||||||
padding: 14px 16px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
.kb-header-title { font-weight: 700; font-size: 15px; color: #fff; flex: 1; }
|
|
||||||
.kb-header-sub { font-size: 11px; color: rgba(255,255,255,.7); }
|
|
||||||
.kb-close-btn {
|
|
||||||
background: rgba(255,255,255,.15);
|
|
||||||
border: none;
|
|
||||||
border-radius: 50%;
|
|
||||||
width: 28px; height: 28px;
|
|
||||||
cursor: pointer;
|
|
||||||
color: #fff;
|
|
||||||
font-size: 16px;
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
}
|
|
||||||
.kb-tabs {
|
|
||||||
display: flex;
|
|
||||||
border-bottom: 1px solid rgba(255,255,255,.08);
|
|
||||||
background: #16162a;
|
|
||||||
}
|
|
||||||
.kb-tab {
|
|
||||||
flex: 1;
|
|
||||||
padding: 10px;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: rgba(255,255,255,.5);
|
|
||||||
font-size: 13px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: color .2s;
|
|
||||||
}
|
|
||||||
.kb-tab.active { color: #a78bfa; border-bottom: 2px solid #7c3aed; font-weight: 600; }
|
|
||||||
.kb-messages {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 14px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
scroll-behavior: smooth;
|
|
||||||
}
|
|
||||||
.kb-messages::-webkit-scrollbar { width: 4px; }
|
|
||||||
.kb-messages::-webkit-scrollbar-track { background: transparent; }
|
|
||||||
.kb-messages::-webkit-scrollbar-thumb { background: rgba(255,255,255,.15); border-radius: 2px; }
|
|
||||||
.kb-bubble {
|
|
||||||
max-width: 88%;
|
|
||||||
padding: 10px 13px;
|
|
||||||
border-radius: 14px;
|
|
||||||
font-size: 13.5px;
|
|
||||||
line-height: 1.5;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
.kb-bubble.user {
|
|
||||||
align-self: flex-end;
|
|
||||||
background: linear-gradient(135deg, #7c3aed, #4f46e5);
|
|
||||||
color: #fff;
|
|
||||||
border-bottom-right-radius: 4px;
|
|
||||||
}
|
|
||||||
.kb-bubble.bot {
|
|
||||||
align-self: flex-start;
|
|
||||||
background: rgba(255,255,255,.07);
|
|
||||||
color: #e2e8f0;
|
|
||||||
border-bottom-left-radius: 4px;
|
|
||||||
}
|
|
||||||
.kb-bubble.error { background: rgba(239,68,68,.15); color: #fca5a5; border: 1px solid rgba(239,68,68,.3); }
|
|
||||||
.kb-typing { align-self: flex-start; color: rgba(255,255,255,.4); font-size: 13px; font-style: italic; }
|
|
||||||
.kb-input-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 12px 14px;
|
|
||||||
border-top: 1px solid rgba(255,255,255,.08);
|
|
||||||
background: #16162a;
|
|
||||||
}
|
|
||||||
.kb-input {
|
|
||||||
flex: 1;
|
|
||||||
background: rgba(255,255,255,.07);
|
|
||||||
border: 1px solid rgba(255,255,255,.12);
|
|
||||||
border-radius: 12px;
|
|
||||||
color: #e2e8f0;
|
|
||||||
font-size: 13.5px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
resize: none;
|
|
||||||
outline: none;
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
.kb-input:focus { border-color: rgba(124,58,237,.6); }
|
|
||||||
.kb-send-btn {
|
|
||||||
background: linear-gradient(135deg, #7c3aed, #4f46e5);
|
|
||||||
border: none;
|
|
||||||
border-radius: 12px;
|
|
||||||
width: 42px;
|
|
||||||
height: 42px;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.kb-send-btn:disabled { opacity: .4; cursor: default; }
|
|
||||||
.kb-send-btn svg { width: 18px; height: 18px; fill: #fff; }
|
|
||||||
.kb-recordings {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 14px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
.kb-rec-item {
|
|
||||||
background: rgba(255,255,255,.05);
|
|
||||||
border: 1px solid rgba(255,255,255,.08);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
}
|
|
||||||
.kb-rec-title { font-weight: 600; font-size: 13px; color: #e2e8f0; }
|
|
||||||
.kb-rec-meta { font-size: 11px; color: rgba(255,255,255,.45); margin-top: 3px; }
|
|
||||||
.kb-rec-status { font-size: 11px; font-weight: 600; margin-top: 4px; }
|
|
||||||
.kb-rec-status.scheduled { color: #fbbf24; }
|
|
||||||
.kb-rec-status.recording { color: #f87171; }
|
|
||||||
.kb-rec-status.completed { color: #34d399; }
|
|
||||||
.kb-rec-status.failed { color: #f87171; }
|
|
||||||
.kb-rec-status.cancelled { color: rgba(255,255,255,.3); }
|
|
||||||
.kb-empty { text-align: center; color: rgba(255,255,255,.35); font-size: 13px; padding: 40px 0; }
|
|
||||||
.kb-bot-avatar { width: 22px; height: 22px; flex-shrink: 0; }
|
|
||||||
#kb-chat-view { display: flex; flex-direction: column; flex: 1; overflow: hidden; min-height: 0; }
|
|
||||||
#kb-rec-view { display: none; flex-direction: column; flex: 1; overflow: hidden; min-height: 0; }
|
|
||||||
`;
|
|
||||||
document.head.appendChild(style);
|
|
||||||
|
|
||||||
// Floating button
|
|
||||||
const btn = document.createElement('button');
|
|
||||||
btn.id = 'kirabot-btn';
|
|
||||||
btn.title = 'KiraBot — Asistente de TV';
|
|
||||||
btn.innerHTML = `
|
|
||||||
<svg viewBox="0 0 24 24"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zM9 11H7V9h2v2zm4 0h-2V9h2v2zm4 0h-2V9h2v2z"/></svg>
|
|
||||||
<span id="kirabot-badge"></span>
|
|
||||||
`;
|
|
||||||
btn.addEventListener('click', () => this.toggle());
|
|
||||||
document.body.appendChild(btn);
|
|
||||||
|
|
||||||
// Chat panel
|
|
||||||
const panel = document.createElement('div');
|
|
||||||
panel.id = 'kirabot-panel';
|
|
||||||
panel.className = 'hidden';
|
|
||||||
panel.innerHTML = `
|
|
||||||
<div class="kb-header">
|
|
||||||
<svg class="kb-bot-avatar" viewBox="0 0 24 24" fill="white"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2z"/></svg>
|
|
||||||
<div>
|
|
||||||
<div class="kb-header-title">KiraBot</div>
|
|
||||||
<div class="kb-header-sub">Asistente de TV · IA</div>
|
|
||||||
</div>
|
|
||||||
<button class="kb-close-btn" id="kb-close">✕</button>
|
|
||||||
</div>
|
|
||||||
<div class="kb-tabs">
|
|
||||||
<button class="kb-tab active" id="kb-tab-chat">💬 Chat</button>
|
|
||||||
<button class="kb-tab" id="kb-tab-rec">📹 Grabaciones</button>
|
|
||||||
</div>
|
|
||||||
<div id="kb-chat-view">
|
|
||||||
<div class="kb-messages" id="kb-messages"></div>
|
|
||||||
<div class="kb-input-row">
|
|
||||||
<textarea class="kb-input" id="kb-input" rows="1"
|
|
||||||
placeholder="Pregunta por la programación, pide grabaciones..."></textarea>
|
|
||||||
<button class="kb-send-btn" id="kb-send" title="Enviar">
|
|
||||||
<svg viewBox="0 0 24 24"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="kb-rec-view" style="display:none;flex:1;overflow:hidden;flex-direction:column;">
|
|
||||||
<div class="kb-recordings" id="kb-recordings"></div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
document.body.appendChild(panel);
|
|
||||||
|
|
||||||
// Events
|
|
||||||
document.getElementById('kb-close').addEventListener('click', () => this.close());
|
|
||||||
document.getElementById('kb-tab-chat').addEventListener('click', () => this._switchTab('chat'));
|
|
||||||
document.getElementById('kb-tab-rec').addEventListener('click', () => this._switchTab('recordings'));
|
|
||||||
document.getElementById('kb-send').addEventListener('click', () => this._sendMessage());
|
|
||||||
|
|
||||||
// Stop all keyboard and mouse events from bubbling to NodeCast's global handlers
|
|
||||||
// (NodeCast intercepts spacebar for play/pause and other keys for media control)
|
|
||||||
const inputEl = document.getElementById('kb-input');
|
|
||||||
inputEl.addEventListener('keydown', (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); this._sendMessage(); }
|
|
||||||
});
|
|
||||||
inputEl.addEventListener('keypress', (e) => e.stopPropagation());
|
|
||||||
inputEl.addEventListener('keyup', (e) => e.stopPropagation());
|
|
||||||
|
|
||||||
panel.addEventListener('click', (e) => e.stopPropagation());
|
|
||||||
panel.addEventListener('mousedown', (e) => e.stopPropagation());
|
|
||||||
|
|
||||||
// Welcome message
|
|
||||||
this._appendBotMessage('¡Hola! Soy KiraBot 👋 Puedo ayudarte con la programación de TV y programar grabaciones. ¿En qué te puedo ayudar?');
|
|
||||||
}
|
|
||||||
|
|
||||||
toggle() {
|
|
||||||
this._open ? this.close() : this.open();
|
|
||||||
}
|
|
||||||
|
|
||||||
open() {
|
|
||||||
this._open = true;
|
|
||||||
document.getElementById('kirabot-panel').classList.remove('hidden');
|
|
||||||
document.getElementById('kb-input').focus();
|
|
||||||
this._scrollToBottom();
|
|
||||||
}
|
|
||||||
|
|
||||||
close() {
|
|
||||||
this._open = false;
|
|
||||||
document.getElementById('kirabot-panel').classList.add('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
_switchTab(tab) {
|
|
||||||
this._tab = tab;
|
|
||||||
document.getElementById('kb-tab-chat').classList.toggle('active', tab === 'chat');
|
|
||||||
document.getElementById('kb-tab-rec').classList.toggle('active', tab === 'recordings');
|
|
||||||
document.getElementById('kb-chat-view').style.display = tab === 'chat' ? 'flex' : 'none';
|
|
||||||
document.getElementById('kb-rec-view').style.display = tab === 'recordings' ? 'flex' : 'none';
|
|
||||||
if (tab === 'recordings') this._loadRecordings();
|
|
||||||
}
|
|
||||||
|
|
||||||
_appendBotMessage(text, isError = false) {
|
|
||||||
const msgs = document.getElementById('kb-messages');
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.className = `kb-bubble bot${isError ? ' error' : ''}`;
|
|
||||||
div.textContent = text;
|
|
||||||
msgs.appendChild(div);
|
|
||||||
this._scrollToBottom();
|
|
||||||
return div;
|
|
||||||
}
|
|
||||||
|
|
||||||
_appendUserMessage(text) {
|
|
||||||
const msgs = document.getElementById('kb-messages');
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.className = 'kb-bubble user';
|
|
||||||
div.textContent = text;
|
|
||||||
msgs.appendChild(div);
|
|
||||||
this._scrollToBottom();
|
|
||||||
}
|
|
||||||
|
|
||||||
_showTyping() {
|
|
||||||
const msgs = document.getElementById('kb-messages');
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.className = 'kb-typing';
|
|
||||||
div.id = 'kb-typing';
|
|
||||||
div.textContent = 'KiraBot está escribiendo...';
|
|
||||||
msgs.appendChild(div);
|
|
||||||
this._scrollToBottom();
|
|
||||||
}
|
|
||||||
|
|
||||||
_removeTyping() {
|
|
||||||
document.getElementById('kb-typing')?.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
_scrollToBottom() {
|
|
||||||
const msgs = document.getElementById('kb-messages');
|
|
||||||
if (msgs) setTimeout(() => { msgs.scrollTop = msgs.scrollHeight; }, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
async _sendMessage() {
|
|
||||||
const input = document.getElementById('kb-input');
|
|
||||||
const text = input.value.trim();
|
|
||||||
if (!text || this._loading) return;
|
|
||||||
|
|
||||||
input.value = '';
|
|
||||||
input.style.height = 'auto';
|
|
||||||
this._appendUserMessage(text);
|
|
||||||
this._loading = true;
|
|
||||||
document.getElementById('kb-send').disabled = true;
|
|
||||||
this._showTyping();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const resp = await fetch('/api/kirabot/chat', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${localStorage.getItem('authToken') || ''}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ message: text }),
|
|
||||||
});
|
|
||||||
const data = await resp.json();
|
|
||||||
this._removeTyping();
|
|
||||||
if (resp.ok) {
|
|
||||||
this._appendBotMessage(data.response || 'Sin respuesta');
|
|
||||||
} else {
|
|
||||||
this._appendBotMessage(data.error || 'Error al conectar con KiraBot', true);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
this._removeTyping();
|
|
||||||
this._appendBotMessage('No se pudo conectar con KiraBot. ¿Está el servidor activo?', true);
|
|
||||||
} finally {
|
|
||||||
this._loading = false;
|
|
||||||
document.getElementById('kb-send').disabled = false;
|
|
||||||
input.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async _loadRecordings() {
|
|
||||||
const container = document.getElementById('kb-recordings');
|
|
||||||
container.innerHTML = '<div class="kb-empty">Cargando...</div>';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const resp = await fetch('/api/kirabot/recordings?limit=20', {
|
|
||||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('authToken') || ''}` },
|
|
||||||
});
|
|
||||||
const recs = await resp.json();
|
|
||||||
container.innerHTML = '';
|
|
||||||
|
|
||||||
if (!Array.isArray(recs) || recs.length === 0) {
|
|
||||||
container.innerHTML = '<div class="kb-empty">No tienes grabaciones todavía.<br>¡Pídele a KiraBot que grabe algo!</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusLabels = {
|
|
||||||
scheduled: ['⏳', 'scheduled', 'Programada'],
|
|
||||||
recording: ['🔴', 'recording', 'Grabando'],
|
|
||||||
completed: ['✓', 'completed', 'Completada'],
|
|
||||||
failed: ['✗', 'failed', 'Fallida'],
|
|
||||||
cancelled: ['✗', 'cancelled', 'Cancelada'],
|
|
||||||
};
|
|
||||||
|
|
||||||
recs.forEach((r) => {
|
|
||||||
const [icon, cls, label] = statusLabels[r.status] || ['?', '', r.status];
|
|
||||||
const start = r.start_time ? new Date(r.start_time).toLocaleString('es-ES', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }) : '';
|
|
||||||
const item = document.createElement('div');
|
|
||||||
item.className = 'kb-rec-item';
|
|
||||||
item.innerHTML = `
|
|
||||||
<div class="kb-rec-title">${this._esc(r.title)}</div>
|
|
||||||
<div class="kb-rec-meta">${this._esc(r.channel_name || '')}${start ? ' · ' + start : ''}</div>
|
|
||||||
<div class="kb-rec-status ${cls}">${icon} ${label}</div>
|
|
||||||
`;
|
|
||||||
container.appendChild(item);
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
container.innerHTML = '<div class="kb-empty" style="color:#f87171">Error al cargar las grabaciones.</div>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_esc(str) {
|
|
||||||
return String(str || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize when app is ready
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
// Delay slightly to let auth check happen first
|
|
||||||
setTimeout(() => {
|
|
||||||
if (localStorage.getItem('authToken')) {
|
|
||||||
window.kiraBot = new ChatBot();
|
|
||||||
}
|
|
||||||
}, 500);
|
|
||||||
});
|
|
||||||
@@ -169,7 +169,7 @@ class SourceManager {
|
|||||||
|
|
||||||
const esc = (t) => String(t ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/"/g, '"');
|
const esc = (t) => String(t ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/"/g, '"');
|
||||||
const admin = isAdmin;
|
const admin = isAdmin;
|
||||||
const ownerLine = (source) => admin
|
const ownerLine = admin
|
||||||
? `<div class="source-meta hint">${source.ownerId != null
|
? `<div class="source-meta hint">${source.ownerId != null
|
||||||
? `Propietario: ${esc(this.userNamesById[source.ownerId] || ('#' + source.ownerId))}`
|
? `Propietario: ${esc(this.userNamesById[source.ownerId] || ('#' + source.ownerId))}`
|
||||||
: 'Fuente compartida (todos los usuarios)'}</div>`
|
: 'Fuente compartida (todos los usuarios)'}</div>`
|
||||||
@@ -181,7 +181,7 @@ class SourceManager {
|
|||||||
<div class="source-info">
|
<div class="source-info">
|
||||||
<div class="source-name">${esc(source.name)}</div>
|
<div class="source-name">${esc(source.name)}</div>
|
||||||
<div class="source-url">${esc(source.url)}</div>
|
<div class="source-url">${esc(source.url)}</div>
|
||||||
${ownerLine(source)}
|
${ownerLine}
|
||||||
</div>
|
</div>
|
||||||
<div class="source-actions">
|
<div class="source-actions">
|
||||||
<button class="btn btn-sm btn-secondary" data-action="refresh" title="Refresh Data">${Icons.refresh}</button>
|
<button class="btn btn-sm btn-secondary" data-action="refresh" title="Refresh Data">${Icons.refresh}</button>
|
||||||
|
|||||||
@@ -68,15 +68,6 @@ class VideoPlayer {
|
|||||||
try {
|
try {
|
||||||
const serverSettings = await API.settings.get();
|
const serverSettings = await API.settings.get();
|
||||||
this.settings = { ...this.getDefaultSettings(), ...serverSettings };
|
this.settings = { ...this.getDefaultSettings(), ...serverSettings };
|
||||||
if (typeof window !== 'undefined' && window.kiratvDesktop?.isDesktopApp) {
|
|
||||||
Object.assign(this.settings, {
|
|
||||||
autoTranscode: false,
|
|
||||||
forceVideoTranscode: false,
|
|
||||||
forceTranscode: false,
|
|
||||||
forceRemux: false
|
|
||||||
});
|
|
||||||
console.log('[Player] App escritorio: sin transcodificar en servidor; reproducirás con mpv nativo.');
|
|
||||||
}
|
|
||||||
this.settingsLoaded = true;
|
this.settingsLoaded = true;
|
||||||
console.log('[Player] Settings loaded from server');
|
console.log('[Player] Settings loaded from server');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -218,9 +209,8 @@ class VideoPlayer {
|
|||||||
|
|
||||||
// Play/Pause toggle
|
// Play/Pause toggle
|
||||||
const togglePlay = () => {
|
const togglePlay = () => {
|
||||||
if (!this.video.src) return; // sin fuente → ignorar (evita NotSupportedError)
|
|
||||||
if (this.video.paused) {
|
if (this.video.paused) {
|
||||||
this.video.play().catch(() => {});
|
this.video.play();
|
||||||
} else {
|
} else {
|
||||||
this.video.pause();
|
this.video.pause();
|
||||||
}
|
}
|
||||||
@@ -900,43 +890,6 @@ class VideoPlayer {
|
|||||||
// Stop current playback (sends DELETE to worker → FFmpeg dies → Dispatcharr frees the slot)
|
// Stop current playback (sends DELETE to worker → FFmpeg dies → Dispatcharr frees the slot)
|
||||||
await this.stop();
|
await this.stop();
|
||||||
|
|
||||||
if (typeof window.kiratvPlayLiveInNativePlayer === 'function' && window.kiratvDesktop?.isDesktopApp) {
|
|
||||||
this.lastSwitchAt = Date.now();
|
|
||||||
this.overlay.classList.add('hidden');
|
|
||||||
this.controlsOverlay?.classList.remove('hidden');
|
|
||||||
this.loadingSpinner?.classList.remove('show');
|
|
||||||
|
|
||||||
const absUrl = window.kiratvAbsolutePlaybackUrl
|
|
||||||
? window.kiratvAbsolutePlaybackUrl(streamUrl)
|
|
||||||
: streamUrl;
|
|
||||||
this.currentUrl = streamUrl;
|
|
||||||
|
|
||||||
console.log('[KiraTV Desktop] Lanzando mpv con URL:', absUrl);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await window.kiratvPlayLiveInNativePlayer(absUrl);
|
|
||||||
console.log('[KiraTV Desktop] resultado mpv:', JSON.stringify(result));
|
|
||||||
if (!result?.ok) {
|
|
||||||
console.error('[KiraTV Desktop] mpv error:', result?.error);
|
|
||||||
this.showError(
|
|
||||||
(result?.error || 'mpv no arrancó.') +
|
|
||||||
' Instala mpv (sudo apt install mpv en Linux) o coloca el binario en resources/binaries/mpv/.'
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
this.updateTranscodeStatus('direct', 'mpv nativo');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[KiraTV Desktop] excepción:', e);
|
|
||||||
this.showError(String(e.message || e));
|
|
||||||
}
|
|
||||||
|
|
||||||
this.updateNowPlaying(channel);
|
|
||||||
this.showNowPlayingOverlay();
|
|
||||||
this.fetchEpgData(channel);
|
|
||||||
window.dispatchEvent(new CustomEvent('channelChanged', { detail: channel }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Give Dispatcharr enough time to release the stream slot before probing the new channel.
|
// Give Dispatcharr enough time to release the stream slot before probing the new channel.
|
||||||
// 2500ms covers most providers; retries in the probe endpoint handle the edge cases.
|
// 2500ms covers most providers; retries in the probe endpoint handle the edge cases.
|
||||||
await new Promise(resolve => setTimeout(resolve, 2500));
|
await new Promise(resolve => setTimeout(resolve, 2500));
|
||||||
@@ -1050,7 +1003,20 @@ class VideoPlayer {
|
|||||||
const playlistUrl = await this.startTranscodeSession(streamUrl, { videoMode: 'encode' });
|
const playlistUrl = await this.startTranscodeSession(streamUrl, { videoMode: 'encode' });
|
||||||
this.currentUrl = playlistUrl;
|
this.currentUrl = playlistUrl;
|
||||||
|
|
||||||
|
// Load HLS
|
||||||
|
this.updateNowPlaying(channel, 'Transcoding (Video)');
|
||||||
|
// ... (rest is same logic flow, simplified by just falling through to playHls call if I refactored)
|
||||||
|
// But for minimize drift, I'll copy the block logic for HLS playback init
|
||||||
|
// Actually, I can just fall through if I set looksLikeHls = true?
|
||||||
|
// No, play logic is sequential.
|
||||||
if (Hls.isSupported()) {
|
if (Hls.isSupported()) {
|
||||||
|
// Start HLS
|
||||||
|
// ... this repeats code. I should probably just set currentUrl and let HLS block handle?
|
||||||
|
// But HLS block is lower down.
|
||||||
|
// I will just execute the HLS init here as before.
|
||||||
|
|
||||||
|
// Actually, easiest way is to re-assign streamUrl and goto start? No.
|
||||||
|
// Copy existing forceTranscode block logic
|
||||||
if (this.hls) {
|
if (this.hls) {
|
||||||
this.hls.destroy();
|
this.hls.destroy();
|
||||||
}
|
}
|
||||||
@@ -1060,6 +1026,7 @@ class VideoPlayer {
|
|||||||
this.hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
this.hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||||
this.video.play().catch(console.error);
|
this.video.play().catch(console.error);
|
||||||
});
|
});
|
||||||
|
// Handle errors
|
||||||
this.hls.on(Hls.Events.ERROR, (event, data) => {
|
this.hls.on(Hls.Events.ERROR, (event, data) => {
|
||||||
if (data.fatal) {
|
if (data.fatal) {
|
||||||
console.log('[Player] HLS fatal error');
|
console.log('[Player] HLS fatal error');
|
||||||
@@ -1067,19 +1034,8 @@ class VideoPlayer {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.updateNowPlaying(channel);
|
return; // Exit
|
||||||
this.showNowPlayingOverlay();
|
|
||||||
this.fetchEpgData(channel);
|
|
||||||
window.dispatchEvent(new CustomEvent('channelChanged', { detail: channel }));
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
console.warn('[Player] HLS.js (MSE) not available; usando HLS nativo si el navegador lo permite…');
|
|
||||||
this.playHls(playlistUrl);
|
|
||||||
this.updateNowPlaying(channel);
|
|
||||||
this.showNowPlayingOverlay();
|
|
||||||
this.fetchEpgData(channel);
|
|
||||||
window.dispatchEvent(new CustomEvent('channelChanged', { detail: channel }));
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CHECK: Force Audio Transcode (Copy Video) - legacy forceTranscode setting
|
// CHECK: Force Audio Transcode (Copy Video) - legacy forceTranscode setting
|
||||||
@@ -1151,28 +1107,13 @@ class VideoPlayer {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Raw .ts URLs: browsers need remux (or transcode). Mirror "Force Remux" when Smart
|
// If raw TS detected without Force Remux enabled, show error
|
||||||
// transcode is on so a failed/missed probe does not dead-end here with a confusing error.
|
|
||||||
if (isRawTs && !this.settings.forceRemux) {
|
if (isRawTs && !this.settings.forceRemux) {
|
||||||
if (this.settings.autoTranscode) {
|
|
||||||
console.log('[Player] Raw MPEG-TS (.ts) + Auto Transcode — remux automático (servidor → fMP4)');
|
|
||||||
this.updateTranscodeStatus('remuxing', 'Remux (Auto TS)');
|
|
||||||
const remuxUrl = this.getRemuxUrl(streamUrl);
|
|
||||||
this.video.src = remuxUrl;
|
|
||||||
this.video.play().catch((e) => {
|
|
||||||
if (e.name !== 'AbortError') console.log('[Player] Autoplay prevented:', e);
|
|
||||||
});
|
|
||||||
this.updateNowPlaying(channel);
|
|
||||||
this.showNowPlayingOverlay();
|
|
||||||
this.fetchEpgData(channel);
|
|
||||||
window.dispatchEvent(new CustomEvent('channelChanged', { detail: channel }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.warn('[Player] Raw MPEG-TS stream detected. Browsers cannot play .ts files directly.');
|
console.warn('[Player] Raw MPEG-TS stream detected. Browsers cannot play .ts files directly.');
|
||||||
this.showError(
|
this.showError(
|
||||||
'This stream uses raw MPEG-TS format (.ts) which browsers cannot play directly.<br><br>' +
|
'This stream uses raw MPEG-TS format (.ts) which browsers cannot play directly.<br><br>' +
|
||||||
'<strong>To fix this:</strong><br>' +
|
'<strong>To fix this:</strong><br>' +
|
||||||
'1. Enable <strong>"Auto Transcode (Smart)"</strong> or <strong>"Force Remux"</strong> in Settings → Streaming<br>' +
|
'1. Enable <strong>"Force Remux"</strong> in Settings → Streaming<br>' +
|
||||||
'2. Or configure your source to output HLS (.m3u8) format'
|
'2. Or configure your source to output HLS (.m3u8) format'
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -1297,25 +1238,6 @@ class VideoPlayer {
|
|||||||
playHls(url) {
|
playHls(url) {
|
||||||
if (this.hls) {
|
if (this.hls) {
|
||||||
this.hls.destroy();
|
this.hls.destroy();
|
||||||
this.hls = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nativeHls =
|
|
||||||
this.video.canPlayType('application/vnd.apple.mpegurl') === 'probably' ||
|
|
||||||
this.video.canPlayType('application/vnd.apple.mpegurl') === 'maybe';
|
|
||||||
|
|
||||||
if ((!Hls.isSupported() || typeof Hls === 'undefined') && nativeHls) {
|
|
||||||
console.log('[Player] HLS playlist via reproducción nativa (sin MSE)');
|
|
||||||
this.video.src = url;
|
|
||||||
this.video.play().catch((e) => {
|
|
||||||
if (e.name !== 'AbortError') console.log('Autoplay prevented:', e);
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Hls.isSupported()) {
|
|
||||||
console.error('[Player] Este navegador no soporta HLS (ni MSE ni HLS nativo); no se puede reproducir la lista');
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.hls = new Hls(this.getHlsConfig());
|
this.hls = new Hls(this.getHlsConfig());
|
||||||
@@ -1512,10 +1434,6 @@ class VideoPlayer {
|
|||||||
* Stop playback
|
* Stop playback
|
||||||
*/
|
*/
|
||||||
async stop() {
|
async stop() {
|
||||||
if (typeof window !== 'undefined' && window.kiratvDesktop?.stopExternalMpv) {
|
|
||||||
await window.kiratvDesktop.stopExternalMpv().catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop any running transcode session first
|
// Stop any running transcode session first
|
||||||
await this.stopTranscodeSession();
|
await this.stopTranscodeSession();
|
||||||
|
|
||||||
@@ -1524,11 +1442,8 @@ class VideoPlayer {
|
|||||||
this.hls = null;
|
this.hls = null;
|
||||||
}
|
}
|
||||||
this.video.pause();
|
this.video.pause();
|
||||||
// En modo escritorio el vídeo va a mpv; limpiar src dispara NotSupportedError
|
this.video.src = '';
|
||||||
if (!window.kiratvDesktop?.isDesktopApp) {
|
this.video.load();
|
||||||
this.video.src = '';
|
|
||||||
this.video.load();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset UI to idle state
|
// Reset UI to idle state
|
||||||
this.overlay.classList.remove('hidden'); // Show "Select a channel"
|
this.overlay.classList.remove('hidden'); // Show "Select a channel"
|
||||||
|
|||||||
@@ -238,7 +238,7 @@ class MoviesPage {
|
|||||||
this.container.innerHTML = '';
|
this.container.innerHTML = '';
|
||||||
|
|
||||||
if (this.filteredMovies.length === 0) {
|
if (this.filteredMovies.length === 0) {
|
||||||
this.container.innerHTML = '<div class="empty-state"><p>No se han encontrado películas</p></div>';
|
this.container.innerHTML = '<div class="empty-state"><p>No movies found</p></div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,8 +278,7 @@ class MoviesPage {
|
|||||||
card.dataset.movieId = movie.stream_id;
|
card.dataset.movieId = movie.stream_id;
|
||||||
card.dataset.sourceId = movie.sourceId;
|
card.dataset.sourceId = movie.sourceId;
|
||||||
|
|
||||||
const rawPoster = movie.stream_icon || movie.cover || '';
|
const poster = movie.stream_icon || movie.cover || '/img/placeholder.png';
|
||||||
const poster = rawPoster.startsWith('http') ? `/api/proxy/image?url=${encodeURIComponent(rawPoster)}` : (rawPoster || '/img/placeholder.png');
|
|
||||||
const year = movie.year || movie.releaseDate?.substring(0, 4) || '';
|
const year = movie.year || movie.releaseDate?.substring(0, 4) || '';
|
||||||
const rating = movie.rating ? `${Icons.star} ${movie.rating}` : '';
|
const rating = movie.rating ? `${Icons.star} ${movie.rating}` : '';
|
||||||
|
|
||||||
@@ -352,7 +351,6 @@ class MoviesPage {
|
|||||||
description: movie.plot || '',
|
description: movie.plot || '',
|
||||||
year: movie.year || movie.releaseDate?.substring(0, 4),
|
year: movie.year || movie.releaseDate?.substring(0, 4),
|
||||||
rating: movie.rating,
|
rating: movie.rating,
|
||||||
durationSec: movie.duration_secs || movie.durationSeconds || movie.duration || movie.run_time,
|
|
||||||
sourceId: movie.sourceId,
|
sourceId: movie.sourceId,
|
||||||
categoryId: movie.category_id,
|
categoryId: movie.category_id,
|
||||||
containerExtension: container
|
containerExtension: container
|
||||||
|
|||||||
@@ -250,7 +250,7 @@ class SeriesPage {
|
|||||||
this.container.innerHTML = '';
|
this.container.innerHTML = '';
|
||||||
|
|
||||||
if (this.filteredSeries.length === 0) {
|
if (this.filteredSeries.length === 0) {
|
||||||
this.container.innerHTML = '<div class="empty-state"><p>No se han encontrado series</p></div>';
|
this.container.innerHTML = '<div class="empty-state"><p>No series found</p></div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,8 +288,7 @@ class SeriesPage {
|
|||||||
card.dataset.seriesId = series.series_id;
|
card.dataset.seriesId = series.series_id;
|
||||||
card.dataset.sourceId = series.sourceId;
|
card.dataset.sourceId = series.sourceId;
|
||||||
|
|
||||||
const rawPoster = series.cover || '';
|
const poster = series.cover || '/img/placeholder.png';
|
||||||
const poster = rawPoster.startsWith('http') ? `/api/proxy/image?url=${encodeURIComponent(rawPoster)}` : (rawPoster || '/img/placeholder.png');
|
|
||||||
const year = series.year || series.releaseDate?.substring(0, 4) || '';
|
const year = series.year || series.releaseDate?.substring(0, 4) || '';
|
||||||
const rating = series.rating ? `${Icons.star} ${series.rating}` : '';
|
const rating = series.rating ? `${Icons.star} ${series.rating}` : '';
|
||||||
|
|
||||||
@@ -351,8 +350,7 @@ class SeriesPage {
|
|||||||
this.detailsPanel.classList.remove('hidden');
|
this.detailsPanel.classList.remove('hidden');
|
||||||
|
|
||||||
// Set header info
|
// Set header info
|
||||||
const rawCover = series.cover || '';
|
document.getElementById('series-poster').src = series.cover || '/img/placeholder.png';
|
||||||
document.getElementById('series-poster').src = rawCover.startsWith('http') ? `/api/proxy/image?url=${encodeURIComponent(rawCover)}` : (rawCover || '/img/placeholder.png');
|
|
||||||
document.getElementById('series-title').textContent = series.name;
|
document.getElementById('series-title').textContent = series.name;
|
||||||
document.getElementById('series-plot').textContent = series.plot || '';
|
document.getElementById('series-plot').textContent = series.plot || '';
|
||||||
|
|
||||||
@@ -364,7 +362,7 @@ class SeriesPage {
|
|||||||
const info = await API.proxy.xtream.seriesInfo(series.sourceId, series.series_id);
|
const info = await API.proxy.xtream.seriesInfo(series.sourceId, series.series_id);
|
||||||
|
|
||||||
if (!info || !info.episodes) {
|
if (!info || !info.episodes) {
|
||||||
this.seasonsContainer.innerHTML = '<p class="hint">No hay episodios</p>';
|
this.seasonsContainer.innerHTML = '<p class="hint">No episodes found</p>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -381,13 +379,13 @@ class SeriesPage {
|
|||||||
<div class="season-group">
|
<div class="season-group">
|
||||||
<div class="season-header">
|
<div class="season-header">
|
||||||
<span class="season-expander">${Icons.chevronDown}</span>
|
<span class="season-expander">${Icons.chevronDown}</span>
|
||||||
<span class="season-name">Temporada ${seasonNum} (${episodes.length} episodios)</span>
|
<span class="season-name">Season ${seasonNum} (${episodes.length} episodes)</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="episode-list">
|
<div class="episode-list">
|
||||||
${episodes.map(ep => `
|
${episodes.map(ep => `
|
||||||
<div class="episode-item" data-episode-id="${ep.id}" data-source-id="${series.sourceId}" data-container="${ep.container_extension || 'mp4'}">
|
<div class="episode-item" data-episode-id="${ep.id}" data-source-id="${series.sourceId}" data-container="${ep.container_extension || 'mp4'}">
|
||||||
<span class="episode-number">E${ep.episode_num}</span>
|
<span class="episode-number">E${ep.episode_num}</span>
|
||||||
<span class="episode-title">${ep.title || `Episodio ${ep.episode_num}`}</span>
|
<span class="episode-title">${ep.title || `Episode ${ep.episode_num}`}</span>
|
||||||
<span class="episode-duration">${ep.duration || ''}</span>
|
<span class="episode-duration">${ep.duration || ''}</span>
|
||||||
</div>
|
</div>
|
||||||
`).join('')}
|
`).join('')}
|
||||||
@@ -410,7 +408,7 @@ class SeriesPage {
|
|||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading series info:', err);
|
console.error('Error loading series info:', err);
|
||||||
this.seasonsContainer.innerHTML = '<p class="hint" style="color: var(--color-error);">Error al cargar episodios</p>';
|
this.seasonsContainer.innerHTML = '<p class="hint" style="color: var(--color-error);">Error loading episodes</p>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -428,7 +426,7 @@ class SeriesPage {
|
|||||||
// Get season and episode number from the episode element context
|
// Get season and episode number from the episode element context
|
||||||
const seasonGroup = episodeEl.closest('.season-group');
|
const seasonGroup = episodeEl.closest('.season-group');
|
||||||
const seasonHeader = seasonGroup?.querySelector('.season-name')?.textContent || '';
|
const seasonHeader = seasonGroup?.querySelector('.season-name')?.textContent || '';
|
||||||
const seasonMatch = seasonHeader.match(/Temporada (\d+)/);
|
const seasonMatch = seasonHeader.match(/Season (\d+)/);
|
||||||
const seasonNum = seasonMatch ? seasonMatch[1] : '1';
|
const seasonNum = seasonMatch ? seasonMatch[1] : '1';
|
||||||
const episodeNum = episodeEl.querySelector('.episode-number')?.textContent?.replace('E', '') || '1';
|
const episodeNum = episodeEl.querySelector('.episode-number')?.textContent?.replace('E', '') || '1';
|
||||||
|
|
||||||
@@ -439,7 +437,7 @@ class SeriesPage {
|
|||||||
if (result && result.url) {
|
if (result && result.url) {
|
||||||
// Play in dedicated Watch page
|
// Play in dedicated Watch page
|
||||||
if (this.app.pages.watch) {
|
if (this.app.pages.watch) {
|
||||||
const episodeTitle = episodeEl.querySelector('.episode-title')?.textContent || `Episodio ${episodeNum}`;
|
const episodeTitle = episodeEl.querySelector('.episode-title')?.textContent || `Episode ${episodeNum}`;
|
||||||
|
|
||||||
this.app.pages.watch.play({
|
this.app.pages.watch.play({
|
||||||
type: 'series',
|
type: 'series',
|
||||||
@@ -455,7 +453,6 @@ class SeriesPage {
|
|||||||
seriesInfo: this.currentSeriesInfo,
|
seriesInfo: this.currentSeriesInfo,
|
||||||
currentSeason: seasonNum,
|
currentSeason: seasonNum,
|
||||||
currentEpisode: episodeNum,
|
currentEpisode: episodeNum,
|
||||||
durationSec: episodeEl.querySelector('.episode-duration')?.textContent || '',
|
|
||||||
containerExtension: container
|
containerExtension: container
|
||||||
}, result.url);
|
}, result.url);
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-19
@@ -325,11 +325,11 @@ class SettingsPage {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await API.users.create({ username, password, role });
|
await API.users.create({ username, password, role });
|
||||||
alert('Usuario creado correctamente.');
|
alert('User created successfully!');
|
||||||
addUserForm.reset();
|
addUserForm.reset();
|
||||||
this.loadUsers();
|
this.loadUsers();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('Error al crear usuario: ' + err.message);
|
alert('Error creating user: ' + err.message);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -453,7 +453,7 @@ class SettingsPage {
|
|||||||
console.log('Modal should now be visible!');
|
console.log('Modal should now be visible!');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error populating modal:', err);
|
console.error('Error populating modal:', err);
|
||||||
alert('Error al abrir edición: ' + err.message);
|
alert('Error opening edit modal: ' + err.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -493,7 +493,7 @@ class SettingsPage {
|
|||||||
closeModal();
|
closeModal();
|
||||||
this.loadUsers();
|
this.loadUsers();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('Error al actualizar usuario: ' + err.message);
|
alert('Error updating user: ' + err.message);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -510,7 +510,7 @@ class SettingsPage {
|
|||||||
await API.users.delete(userId);
|
await API.users.delete(userId);
|
||||||
this.loadUsers();
|
this.loadUsers();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('Error al eliminar usuario: ' + err.message);
|
alert('Error deleting user: ' + err.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -523,11 +523,6 @@ class SettingsPage {
|
|||||||
this.app.sourceManager.loadContentSources();
|
this.app.sourceManager.loadContentSources();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refrescar listas al volver a Fuentes (evita lista vacía tras crear/asignar)
|
|
||||||
if (tabName === 'sources') {
|
|
||||||
this.app.sourceManager.loadSources();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load users when switching to users tab
|
// Load users when switching to users tab
|
||||||
if (tabName === 'users') {
|
if (tabName === 'users') {
|
||||||
this.loadUsers();
|
this.loadUsers();
|
||||||
@@ -623,25 +618,26 @@ class SettingsPage {
|
|||||||
|
|
||||||
let text;
|
let text;
|
||||||
if (diffMins < 1) {
|
if (diffMins < 1) {
|
||||||
text = 'Hace un momento';
|
text = 'Just now';
|
||||||
} else if (diffMins < 60) {
|
} else if (diffMins < 60) {
|
||||||
text = `Hace ${diffMins} min`;
|
text = `${diffMins} minute${diffMins === 1 ? '' : 's'} ago`;
|
||||||
} else if (diffHours < 24) {
|
} else if (diffHours < 24) {
|
||||||
text = `Hace ${diffHours} h`;
|
text = `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`;
|
||||||
} else {
|
} else {
|
||||||
text = lastRefreshTime.toLocaleString('es-ES');
|
// Use absolute time for older refreshes
|
||||||
|
text = lastRefreshTime.toLocaleString();
|
||||||
}
|
}
|
||||||
|
|
||||||
display.textContent = text;
|
display.textContent = text;
|
||||||
display.title = lastRefreshTime.toLocaleString('es-ES');
|
display.title = lastRefreshTime.toLocaleString(); // Full timestamp on hover
|
||||||
} else {
|
} else {
|
||||||
display.textContent = 'Nunca';
|
display.textContent = 'Never';
|
||||||
display.title = 'La sincronización aún no se ha ejecutado';
|
display.title = 'Sync has not run yet since server started';
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching sync status:', err);
|
console.error('Error fetching sync status:', err);
|
||||||
display.textContent = '—';
|
display.textContent = 'Unknown';
|
||||||
display.title = 'No se pudo obtener el estado de sincronización';
|
display.title = 'Could not fetch sync status';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+27
-255
@@ -79,11 +79,6 @@ class WatchPage {
|
|||||||
this.isFavorite = false;
|
this.isFavorite = false;
|
||||||
this.returnPage = null;
|
this.returnPage = null;
|
||||||
this.captionsMenuOpen = false;
|
this.captionsMenuOpen = false;
|
||||||
this.sourceStreamUrl = null;
|
|
||||||
this.knownDurationSec = 0;
|
|
||||||
this.playbackBaseOffsetSec = 0;
|
|
||||||
this.isSessionMode = false;
|
|
||||||
this._heartbeatTimer = null;
|
|
||||||
|
|
||||||
// Overlay timer
|
// Overlay timer
|
||||||
this.overlayTimeout = null;
|
this.overlayTimeout = null;
|
||||||
@@ -243,20 +238,6 @@ class WatchPage {
|
|||||||
this.scrollHint?.classList.remove('hidden');
|
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.currentEpisode = content.currentEpisode || null;
|
||||||
this.resumeTime = content.resumeTime || 0;
|
this.resumeTime = content.resumeTime || 0;
|
||||||
this.containerExtension = content.containerExtension || 'mp4';
|
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';
|
this.returnPage = content.type === 'movie' ? 'movies' : 'series';
|
||||||
|
|
||||||
// Stop any Live TV playback before starting movie/series
|
// Stop any Live TV playback before starting movie/series
|
||||||
@@ -293,14 +271,6 @@ class WatchPage {
|
|||||||
// Update title bar
|
// Update title bar
|
||||||
this.titleEl.textContent = content.title || '';
|
this.titleEl.textContent = content.title || '';
|
||||||
this.subtitleEl.textContent = content.subtitle || '';
|
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
|
// Load video
|
||||||
await this.loadVideo(streamUrl);
|
await this.loadVideo(streamUrl);
|
||||||
@@ -338,7 +308,7 @@ class WatchPage {
|
|||||||
const indicator = document.getElementById('now-playing-indicator');
|
const indicator = document.getElementById('now-playing-indicator');
|
||||||
const textEl = document.getElementById('now-playing-text');
|
const textEl = document.getElementById('now-playing-text');
|
||||||
if (indicator && textEl) {
|
if (indicator && textEl) {
|
||||||
textEl.textContent = title || 'Reproduciendo';
|
textEl.textContent = title || 'Now Playing';
|
||||||
indicator.classList.remove('hidden');
|
indicator.classList.remove('hidden');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -371,25 +341,11 @@ class WatchPage {
|
|||||||
if (!res.ok) throw new Error('Failed to start session');
|
if (!res.ok) throw new Error('Failed to start session');
|
||||||
const session = await res.json();
|
const session = await res.json();
|
||||||
this.currentSessionId = session.sessionId;
|
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;
|
return session.playlistUrl;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[WatchPage] Session start failed:', err);
|
console.error('[WatchPage] Session start failed:', err);
|
||||||
this.isSessionMode = false;
|
// Fallback to direct transcode if session fails
|
||||||
// No direct /api/transcode fallback: evita abrir conexiones duplicadas
|
return `/api/transcode?url=${encodeURIComponent(url)}`;
|
||||||
// contra proveedores con límite estricto de sesiones (Dispatcharr/Jellyfin).
|
|
||||||
throw err;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,21 +353,15 @@ class WatchPage {
|
|||||||
* Stop and cleanup current transcode session
|
* Stop and cleanup current transcode session
|
||||||
*/
|
*/
|
||||||
async stopTranscodeSession() {
|
async stopTranscodeSession() {
|
||||||
this._stopHeartbeat();
|
|
||||||
if (this.currentSessionId) {
|
if (this.currentSessionId) {
|
||||||
console.log('[WatchPage] Stopping transcode session:', this.currentSessionId);
|
console.log('[WatchPage] Stopping transcode session:', this.currentSessionId);
|
||||||
const sid = this.currentSessionId;
|
|
||||||
this.currentSessionId = null;
|
|
||||||
try {
|
try {
|
||||||
// sendBeacon works even when the page is being unloaded
|
// Fire and forget cleanup
|
||||||
if (navigator.sendBeacon) {
|
fetch(`/api/transcode/${this.currentSessionId}`, { method: 'DELETE' });
|
||||||
navigator.sendBeacon(`/api/transcode/${sid}/stop`, '{}');
|
|
||||||
}
|
|
||||||
// Also DELETE (for normal navigation)
|
|
||||||
fetch(`/api/transcode/${sid}`, { method: 'DELETE' }).catch(() => {});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to stop session:', err);
|
console.error('Failed to stop session:', err);
|
||||||
}
|
}
|
||||||
|
this.currentSessionId = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -462,7 +412,6 @@ class WatchPage {
|
|||||||
async loadVideo(url) {
|
async loadVideo(url) {
|
||||||
// Store the URL for copy functionality
|
// Store the URL for copy functionality
|
||||||
this.currentUrl = url;
|
this.currentUrl = url;
|
||||||
this.sourceStreamUrl = url;
|
|
||||||
|
|
||||||
// Stop any existing playback
|
// Stop any existing playback
|
||||||
this.stop();
|
this.stop();
|
||||||
@@ -470,23 +419,6 @@ class WatchPage {
|
|||||||
// Show loading spinner
|
// Show loading spinner
|
||||||
this.showLoading();
|
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
|
// Get settings for proxy/transcode
|
||||||
let settings = {};
|
let settings = {};
|
||||||
try {
|
try {
|
||||||
@@ -530,7 +462,6 @@ class WatchPage {
|
|||||||
audioCodec: info.audio,
|
audioCodec: info.audio,
|
||||||
audioChannels: info.audioChannels
|
audioChannels: info.audioChannels
|
||||||
});
|
});
|
||||||
this.playbackBaseOffsetSec = this.resumeTime || 0;
|
|
||||||
this.playHls(playlistUrl);
|
this.playHls(playlistUrl);
|
||||||
this.setVolumeFromStorage();
|
this.setVolumeFromStorage();
|
||||||
return;
|
return;
|
||||||
@@ -550,30 +481,8 @@ class WatchPage {
|
|||||||
// Compatible - fall through to normal playback
|
// Compatible - fall through to normal playback
|
||||||
console.log('[WatchPage] Auto: Using normal playback (compatible)');
|
console.log('[WatchPage] Auto: Using normal playback (compatible)');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[WatchPage] Probe failed:', err.message);
|
console.warn('[WatchPage] Probe failed, using normal playback:', err.message);
|
||||||
const vodLike =
|
// Continue with normal playback on probe failure
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -587,7 +496,6 @@ class WatchPage {
|
|||||||
videoMode: 'encode',
|
videoMode: 'encode',
|
||||||
seekOffset: this.resumeTime
|
seekOffset: this.resumeTime
|
||||||
});
|
});
|
||||||
this.playbackBaseOffsetSec = this.resumeTime || 0;
|
|
||||||
this.playHls(playlistUrl);
|
this.playHls(playlistUrl);
|
||||||
this.setVolumeFromStorage();
|
this.setVolumeFromStorage();
|
||||||
return;
|
return;
|
||||||
@@ -611,7 +519,6 @@ class WatchPage {
|
|||||||
videoCodec,
|
videoCodec,
|
||||||
seekOffset: this.resumeTime
|
seekOffset: this.resumeTime
|
||||||
});
|
});
|
||||||
this.playbackBaseOffsetSec = this.resumeTime || 0;
|
|
||||||
this.playHls(playlistUrl);
|
this.playHls(playlistUrl);
|
||||||
this.setVolumeFromStorage();
|
this.setVolumeFromStorage();
|
||||||
return;
|
return;
|
||||||
@@ -623,7 +530,6 @@ class WatchPage {
|
|||||||
this.updateTranscodeStatus('remuxing', 'Remux (Force)');
|
this.updateTranscodeStatus('remuxing', 'Remux (Force)');
|
||||||
const finalUrl = `/api/remux?url=${encodeURIComponent(url)}`;
|
const finalUrl = `/api/remux?url=${encodeURIComponent(url)}`;
|
||||||
this.video.src = finalUrl;
|
this.video.src = finalUrl;
|
||||||
this.playbackBaseOffsetSec = 0;
|
|
||||||
this.video.play().catch(e => {
|
this.video.play().catch(e => {
|
||||||
if (e.name !== 'AbortError') console.error('[WatchPage] Autoplay error:', e);
|
if (e.name !== 'AbortError') console.error('[WatchPage] Autoplay error:', e);
|
||||||
});
|
});
|
||||||
@@ -641,12 +547,10 @@ class WatchPage {
|
|||||||
// Use HLS.js for HLS streams
|
// Use HLS.js for HLS streams
|
||||||
if (looksLikeHls && Hls.isSupported()) {
|
if (looksLikeHls && Hls.isSupported()) {
|
||||||
this.updateTranscodeStatus('direct', 'Direct HLS');
|
this.updateTranscodeStatus('direct', 'Direct HLS');
|
||||||
this.playbackBaseOffsetSec = 0;
|
|
||||||
this.playHls(finalUrl);
|
this.playHls(finalUrl);
|
||||||
} else {
|
} else {
|
||||||
// Direct playback for mp4/mkv/avi
|
// Direct playback for mp4/mkv/avi
|
||||||
this.updateTranscodeStatus('direct', 'Direct Play');
|
this.updateTranscodeStatus('direct', 'Direct Play');
|
||||||
this.playbackBaseOffsetSec = 0;
|
|
||||||
this.video.src = finalUrl;
|
this.video.src = finalUrl;
|
||||||
this.video.play().catch(e => {
|
this.video.play().catch(e => {
|
||||||
if (e.name !== 'AbortError') console.error('[WatchPage] Autoplay error:', e);
|
if (e.name !== 'AbortError') console.error('[WatchPage] Autoplay error:', e);
|
||||||
@@ -717,13 +621,6 @@ class WatchPage {
|
|||||||
this.stopHistoryTracking();
|
this.stopHistoryTracking();
|
||||||
this.saveProgress();
|
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
|
// Cleanup transcode session if exists
|
||||||
this.stopTranscodeSession();
|
this.stopTranscodeSession();
|
||||||
this.updateTranscodeStatus('hidden');
|
this.updateTranscodeStatus('hidden');
|
||||||
@@ -740,12 +637,8 @@ class WatchPage {
|
|||||||
}
|
}
|
||||||
if (this.video) {
|
if (this.video) {
|
||||||
this.video.pause();
|
this.video.pause();
|
||||||
// En modo escritorio el vídeo va a mpv: limpiar src dispararía un error
|
this.video.src = '';
|
||||||
// inofensivo pero confuso (MEDIA_ELEMENT_ERROR: Empty src attribute).
|
this.video.load();
|
||||||
if (!window.kiratvDesktop?.isDesktopApp) {
|
|
||||||
this.video.src = '';
|
|
||||||
this.video.load();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.hideNowPlaying();
|
this.hideNowPlaying();
|
||||||
@@ -762,38 +655,15 @@ class WatchPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
skip(seconds) {
|
skip(seconds) {
|
||||||
if (!this.video) return;
|
if (this.video) {
|
||||||
const totalDuration = this.getTotalDuration();
|
this.video.currentTime = Math.max(0, Math.min(this.video.currentTime + seconds, this.video.duration || 0));
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
seek(percent) {
|
seek(percent) {
|
||||||
const totalDuration = this.getTotalDuration();
|
if (this.video && this.video.duration) {
|
||||||
if (!this.video || !totalDuration) return;
|
this.video.currentTime = (percent / 100) * this.video.duration;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.video.currentTime = Math.max(0, targetSec);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleMute() {
|
toggleMute() {
|
||||||
@@ -898,22 +768,18 @@ class WatchPage {
|
|||||||
// === UI Updates ===
|
// === UI Updates ===
|
||||||
|
|
||||||
updateProgress() {
|
updateProgress() {
|
||||||
if (!this.video) return;
|
if (!this.video || !this.video.duration) return;
|
||||||
|
|
||||||
const totalDuration = this.getTotalDuration();
|
const percent = (this.video.currentTime / this.video.duration) * 100;
|
||||||
if (!totalDuration) return;
|
this.progressSlider.value = percent;
|
||||||
|
this.timeCurrent.textContent = this.formatTime(this.video.currentTime);
|
||||||
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);
|
|
||||||
|
|
||||||
// Show "Up Next" panel early for series (like streaming services do during credits)
|
// Show "Up Next" panel early for series (like streaming services do during credits)
|
||||||
// Only show if auto-play next episode is enabled
|
// Only show if auto-play next episode is enabled
|
||||||
const autoPlayEnabled = this.app?.player?.settings?.autoPlayNextEpisode;
|
const autoPlayEnabled = this.app?.player?.settings?.autoPlayNextEpisode;
|
||||||
if (autoPlayEnabled && this.contentType === 'series' && this.seriesInfo && !this.nextEpisodeShowing && !this.nextEpisodeDismissed) {
|
if (autoPlayEnabled && this.contentType === 'series' && this.seriesInfo && !this.nextEpisodeShowing && !this.nextEpisodeDismissed) {
|
||||||
const duration = totalDuration;
|
const duration = this.video.duration;
|
||||||
const currentTime = currentAbs;
|
const currentTime = this.video.currentTime;
|
||||||
|
|
||||||
// Only proceed if we have reliable duration data
|
// Only proceed if we have reliable duration data
|
||||||
if (isFinite(duration) && duration >= 180 && currentTime >= 120) {
|
if (isFinite(duration) && duration >= 180 && currentTime >= 120) {
|
||||||
@@ -941,10 +807,8 @@ class WatchPage {
|
|||||||
this.updateQualityBadge();
|
this.updateQualityBadge();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle resumption (only for direct video, NOT session mode).
|
// Handle resumption
|
||||||
// In session mode FFmpeg already starts from the seekOffset, so
|
if (this.resumeTime > 0 && this.video) {
|
||||||
// video.currentTime=0 already represents the correct absolute position.
|
|
||||||
if (!this.isSessionMode && this.resumeTime > 0 && this.video) {
|
|
||||||
const duration = this.video.duration;
|
const duration = this.video.duration;
|
||||||
// Only resume if not near the end (95%)
|
// Only resume if not near the end (95%)
|
||||||
if (!duration || this.resumeTime < duration * 0.95) {
|
if (!duration || this.resumeTime < duration * 0.95) {
|
||||||
@@ -952,14 +816,6 @@ class WatchPage {
|
|||||||
this.video.currentTime = this.resumeTime;
|
this.video.currentTime = this.resumeTime;
|
||||||
}
|
}
|
||||||
this.resumeTime = 0; // Reset after use
|
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -996,11 +852,11 @@ class WatchPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onError(e) {
|
onError(e) {
|
||||||
|
// Only log actual fatal errors, not benign stream recovery events
|
||||||
const error = this.video?.error;
|
const error = this.video?.error;
|
||||||
if (!error || !error.code) return;
|
if (error && error.code) {
|
||||||
// Ignorar error por src vacío (ocurre al limpiar el elemento tras parar)
|
console.error('[WatchPage] Video error:', error.code, error.message);
|
||||||
if (error.code === 4 && (!this.video.src || this.video.src === window.location.href)) return;
|
}
|
||||||
console.error('[WatchPage] Video error:', error.code, error.message);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateVolumeUI() {
|
updateVolumeUI() {
|
||||||
@@ -1020,90 +876,6 @@ class WatchPage {
|
|||||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
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 ===
|
// === Loading Spinner ===
|
||||||
|
|
||||||
showLoading() {
|
showLoading() {
|
||||||
@@ -1645,7 +1417,7 @@ class WatchPage {
|
|||||||
try {
|
try {
|
||||||
const data = {
|
const data = {
|
||||||
title: this.content.title || 'Unknown Title',
|
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,
|
poster: this.content.poster,
|
||||||
sourceId: this.content.sourceId,
|
sourceId: this.content.sourceId,
|
||||||
containerExtension: this.containerExtension,
|
containerExtension: this.containerExtension,
|
||||||
|
|||||||
@@ -1,401 +0,0 @@
|
|||||||
/**
|
|
||||||
* Movies Page Controller
|
|
||||||
* Handles VOD movie browsing and playback
|
|
||||||
*/
|
|
||||||
|
|
||||||
class MoviesPage {
|
|
||||||
constructor(app) {
|
|
||||||
this.app = app;
|
|
||||||
this.container = document.getElementById('movies-grid');
|
|
||||||
this.sourceSelect = document.getElementById('movies-source-select');
|
|
||||||
this.categorySelect = document.getElementById('movies-category-select');
|
|
||||||
this.searchInput = document.getElementById('movies-search');
|
|
||||||
|
|
||||||
this.movies = [];
|
|
||||||
this.categories = [];
|
|
||||||
this.sources = [];
|
|
||||||
this.currentBatch = 0;
|
|
||||||
this.batchSize = 24;
|
|
||||||
this.filteredMovies = [];
|
|
||||||
this.isLoading = false;
|
|
||||||
this.observer = null;
|
|
||||||
this.favoriteIds = new Set(); // Track favorite movie IDs
|
|
||||||
this.showFavoritesOnly = false;
|
|
||||||
|
|
||||||
this.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
init() {
|
|
||||||
// Source change handler
|
|
||||||
this.sourceSelect?.addEventListener('change', async () => {
|
|
||||||
await this.loadCategories();
|
|
||||||
await this.loadMovies();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Category change handler
|
|
||||||
this.categorySelect?.addEventListener('change', () => {
|
|
||||||
this.loadMovies();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Search with debounce
|
|
||||||
let searchTimeout;
|
|
||||||
this.searchInput?.addEventListener('input', () => {
|
|
||||||
clearTimeout(searchTimeout);
|
|
||||||
searchTimeout = setTimeout(() => this.filterAndRender(), 300);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Set up IntersectionObserver for lazy loading
|
|
||||||
this.observer = new IntersectionObserver((entries) => {
|
|
||||||
if (entries[0].isIntersecting && !this.isLoading) {
|
|
||||||
this.renderNextBatch();
|
|
||||||
}
|
|
||||||
}, { rootMargin: '200px' });
|
|
||||||
|
|
||||||
// Favorites filter toggle
|
|
||||||
const favBtn = document.getElementById('movies-favorites-btn');
|
|
||||||
favBtn?.addEventListener('click', () => {
|
|
||||||
this.showFavoritesOnly = !this.showFavoritesOnly;
|
|
||||||
favBtn.classList.toggle('active', this.showFavoritesOnly);
|
|
||||||
this.filterAndRender();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async show() {
|
|
||||||
// Load sources if not loaded
|
|
||||||
if (this.sources.length === 0) {
|
|
||||||
await this.loadSources();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load favorites
|
|
||||||
await this.loadFavorites();
|
|
||||||
|
|
||||||
// Load movies if empty
|
|
||||||
if (this.movies.length === 0) {
|
|
||||||
await this.loadCategories();
|
|
||||||
await this.loadMovies();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
hide() {
|
|
||||||
// Page is hidden
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadFavorites() {
|
|
||||||
try {
|
|
||||||
const favs = await API.favorites.getAll(null, 'movie');
|
|
||||||
this.favoriteIds = new Set(favs.map(f => `${f.source_id}:${f.item_id}`));
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error loading favorites:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async loadSources() {
|
|
||||||
try {
|
|
||||||
const allSources = await API.sources.getAll();
|
|
||||||
this.sources = allSources.filter(s => s.type === 'xtream' && s.enabled);
|
|
||||||
|
|
||||||
this.sourceSelect.innerHTML = '<option value="">All Sources</option>';
|
|
||||||
this.sources.forEach(s => {
|
|
||||||
const option = document.createElement('option');
|
|
||||||
option.value = s.id;
|
|
||||||
option.textContent = s.name;
|
|
||||||
this.sourceSelect.appendChild(option);
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error loading sources:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadCategories() {
|
|
||||||
try {
|
|
||||||
this.categories = [];
|
|
||||||
this.hiddenCategoryIds = new Set(); // Track hidden categories
|
|
||||||
this.categorySelect.innerHTML = '<option value="">All Categories</option>';
|
|
||||||
|
|
||||||
const sourceId = this.sourceSelect.value;
|
|
||||||
const sourcesToLoad = sourceId
|
|
||||||
? this.sources.filter(s => s.id === parseInt(sourceId))
|
|
||||||
: this.sources;
|
|
||||||
|
|
||||||
// Fetch hidden items for each source
|
|
||||||
for (const source of sourcesToLoad) {
|
|
||||||
try {
|
|
||||||
const hiddenItems = await API.channels.getHidden(source.id);
|
|
||||||
hiddenItems.forEach(h => {
|
|
||||||
if (h.item_type === 'vod_category') {
|
|
||||||
this.hiddenCategoryIds.add(`${source.id}:${h.item_id}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(`Failed to load hidden items from source ${source.id}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const source of sourcesToLoad) {
|
|
||||||
try {
|
|
||||||
const cats = await API.proxy.xtream.vodCategories(source.id);
|
|
||||||
if (cats && Array.isArray(cats)) {
|
|
||||||
cats.forEach(c => {
|
|
||||||
// Skip hidden categories
|
|
||||||
if (!this.hiddenCategoryIds.has(`${source.id}:${c.category_id}`)) {
|
|
||||||
this.categories.push({ ...c, sourceId: source.id });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(`Failed to load categories from source ${source.id}:`, err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Populate dropdown
|
|
||||||
this.categories.forEach(c => {
|
|
||||||
const option = document.createElement('option');
|
|
||||||
option.value = `${c.sourceId}:${c.category_id}`;
|
|
||||||
option.textContent = c.category_name;
|
|
||||||
this.categorySelect.appendChild(option);
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error loading categories:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadMovies() {
|
|
||||||
this.isLoading = true;
|
|
||||||
this.container.innerHTML = '<div class="loading"><div class="loading-spinner"></div></div>';
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.movies = [];
|
|
||||||
|
|
||||||
const sourceId = this.sourceSelect.value;
|
|
||||||
const categoryValue = this.categorySelect.value;
|
|
||||||
|
|
||||||
const sourcesToLoad = sourceId
|
|
||||||
? this.sources.filter(s => s.id === parseInt(sourceId))
|
|
||||||
: this.sources;
|
|
||||||
|
|
||||||
for (const source of sourcesToLoad) {
|
|
||||||
try {
|
|
||||||
// Parse category if selected
|
|
||||||
let catId = null;
|
|
||||||
if (categoryValue) {
|
|
||||||
const [catSourceId, categoryId] = categoryValue.split(':');
|
|
||||||
if (parseInt(catSourceId) === source.id) {
|
|
||||||
catId = categoryId;
|
|
||||||
} else if (sourceId) {
|
|
||||||
continue; // Skip this source if category is from different source
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const movies = await API.proxy.xtream.vodStreams(source.id, catId);
|
|
||||||
console.log(`[Movies] Source ${source.id}, Category ${catId || 'ALL'}: Got ${movies?.length || 0} movies`);
|
|
||||||
if (movies && Array.isArray(movies)) {
|
|
||||||
movies.forEach(m => {
|
|
||||||
// Skip movies from hidden categories
|
|
||||||
if (this.hiddenCategoryIds && this.hiddenCategoryIds.has(`${source.id}:${m.category_id}`)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.movies.push({
|
|
||||||
...m,
|
|
||||||
sourceId: source.id,
|
|
||||||
id: `${source.id}:${m.stream_id}`
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(`Failed to load movies from source ${source.id}:`, err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[Movies] Total loaded: ${this.movies.length} movies`);
|
|
||||||
this.filterAndRender();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error loading movies:', err);
|
|
||||||
this.container.innerHTML = '<div class="empty-state"><p>Error loading movies</p></div>';
|
|
||||||
} finally {
|
|
||||||
this.isLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
filterAndRender() {
|
|
||||||
const searchTerm = this.searchInput?.value?.toLowerCase() || '';
|
|
||||||
|
|
||||||
this.filteredMovies = this.movies.filter(m => {
|
|
||||||
// Filter by favorites if enabled
|
|
||||||
if (this.showFavoritesOnly) {
|
|
||||||
const favKey = `${m.sourceId}:${m.stream_id}`;
|
|
||||||
if (!this.favoriteIds.has(favKey)) return false;
|
|
||||||
}
|
|
||||||
if (searchTerm && !m.name?.toLowerCase().includes(searchTerm)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`[Movies] Displaying ${this.filteredMovies.length} of ${this.movies.length} movies`);
|
|
||||||
|
|
||||||
this.currentBatch = 0;
|
|
||||||
this.container.innerHTML = '';
|
|
||||||
|
|
||||||
if (this.filteredMovies.length === 0) {
|
|
||||||
this.container.innerHTML = '<div class="empty-state"><p>No movies found</p></div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create loader element
|
|
||||||
const loader = document.createElement('div');
|
|
||||||
loader.className = 'movies-loader';
|
|
||||||
loader.innerHTML = '<div class="loading-spinner"></div>';
|
|
||||||
this.container.appendChild(loader);
|
|
||||||
|
|
||||||
// Render initial batches (more to fill viewport)
|
|
||||||
for (let i = 0; i < 5; i++) {
|
|
||||||
this.renderNextBatch();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start observing loader
|
|
||||||
this.observer.observe(loader);
|
|
||||||
}
|
|
||||||
|
|
||||||
renderNextBatch() {
|
|
||||||
const start = this.currentBatch * this.batchSize;
|
|
||||||
const end = start + this.batchSize;
|
|
||||||
const batch = this.filteredMovies.slice(start, end);
|
|
||||||
|
|
||||||
console.log(`[Movies] Rendering batch ${this.currentBatch}: ${batch.length} cards (${start}-${end})`);
|
|
||||||
|
|
||||||
if (batch.length === 0) {
|
|
||||||
const loader = this.container.querySelector('.movies-loader');
|
|
||||||
if (loader) loader.style.display = 'none';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fragment = document.createDocumentFragment();
|
|
||||||
|
|
||||||
batch.forEach(movie => {
|
|
||||||
const card = document.createElement('div');
|
|
||||||
card.className = 'movie-card';
|
|
||||||
card.dataset.movieId = movie.stream_id;
|
|
||||||
card.dataset.sourceId = movie.sourceId;
|
|
||||||
|
|
||||||
const poster = movie.stream_icon || movie.cover || '/img/placeholder.png';
|
|
||||||
const year = movie.year || movie.releaseDate?.substring(0, 4) || '';
|
|
||||||
const rating = movie.rating ? `${Icons.star} ${movie.rating}` : '';
|
|
||||||
|
|
||||||
const isFav = this.favoriteIds.has(`${movie.sourceId}:${movie.stream_id}`);
|
|
||||||
|
|
||||||
card.innerHTML = `
|
|
||||||
<div class="movie-poster">
|
|
||||||
<img src="${poster}" alt="${movie.name}"
|
|
||||||
onerror="this.onerror=null;this.src='/img/placeholder.png'" loading="lazy">
|
|
||||||
<div class="movie-play-overlay">
|
|
||||||
<span class="play-icon">${Icons.play}</span>
|
|
||||||
</div>
|
|
||||||
<button class="favorite-btn ${isFav ? 'active' : ''}" title="${isFav ? 'Remove from Favorites' : 'Add to Favorites'}">
|
|
||||||
<span class="fav-icon">${isFav ? Icons.favorite : Icons.favoriteOutline}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="movie-info">
|
|
||||||
<div class="movie-title">${movie.name}</div>
|
|
||||||
<div class="movie-meta">
|
|
||||||
${year ? `<span>${year}</span>` : ''}
|
|
||||||
${rating ? `<span>${rating}</span>` : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Card click plays movie, but not if clicking favorite button
|
|
||||||
card.addEventListener('click', (e) => {
|
|
||||||
if (e.target.closest('.favorite-btn')) {
|
|
||||||
const btn = e.target.closest('.favorite-btn');
|
|
||||||
this.toggleFavorite(movie, btn);
|
|
||||||
e.stopPropagation();
|
|
||||||
} else {
|
|
||||||
this.playMovie(movie);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
fragment.appendChild(card);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Insert before loader
|
|
||||||
const loader = this.container.querySelector('.movies-loader');
|
|
||||||
if (loader) {
|
|
||||||
this.container.insertBefore(fragment, loader);
|
|
||||||
} else {
|
|
||||||
this.container.appendChild(fragment);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.currentBatch++;
|
|
||||||
|
|
||||||
// Hide loader if done
|
|
||||||
if (end >= this.filteredMovies.length && loader) {
|
|
||||||
loader.style.display = 'none';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async playMovie(movie) {
|
|
||||||
try {
|
|
||||||
// Get stream URL for movie using the actual container extension from API
|
|
||||||
// Xtream API returns container_extension (e.g., 'mp4', 'mkv', 'avi')
|
|
||||||
const container = movie.container_extension || 'mp4';
|
|
||||||
const result = await API.proxy.xtream.getStreamUrl(movie.sourceId, movie.stream_id, 'movie', container);
|
|
||||||
|
|
||||||
if (result && result.url) {
|
|
||||||
// Play in dedicated Watch page
|
|
||||||
if (this.app.pages.watch) {
|
|
||||||
this.app.pages.watch.play({
|
|
||||||
type: 'movie',
|
|
||||||
id: movie.stream_id,
|
|
||||||
title: movie.name,
|
|
||||||
poster: movie.stream_icon || movie.cover,
|
|
||||||
description: movie.plot || '',
|
|
||||||
year: movie.year || movie.releaseDate?.substring(0, 4),
|
|
||||||
rating: movie.rating,
|
|
||||||
durationSec: movie.duration_secs || movie.durationSeconds || movie.duration || movie.run_time,
|
|
||||||
sourceId: movie.sourceId,
|
|
||||||
categoryId: movie.category_id,
|
|
||||||
containerExtension: container
|
|
||||||
}, result.url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error playing movie:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async toggleFavorite(movie, btn) {
|
|
||||||
const favKey = `${movie.sourceId}:${movie.stream_id}`;
|
|
||||||
const isFav = this.favoriteIds.has(favKey);
|
|
||||||
const iconSpan = btn.querySelector('.fav-icon');
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Optimistic update
|
|
||||||
if (isFav) {
|
|
||||||
this.favoriteIds.delete(favKey);
|
|
||||||
btn.classList.remove('active');
|
|
||||||
btn.title = 'Add to Favorites';
|
|
||||||
if (iconSpan) iconSpan.innerHTML = Icons.favoriteOutline;
|
|
||||||
await API.favorites.remove(movie.sourceId, movie.stream_id, 'movie');
|
|
||||||
} else {
|
|
||||||
this.favoriteIds.add(favKey);
|
|
||||||
btn.classList.add('active');
|
|
||||||
btn.title = 'Remove from Favorites';
|
|
||||||
if (iconSpan) iconSpan.innerHTML = Icons.favorite;
|
|
||||||
await API.favorites.add(movie.sourceId, movie.stream_id, 'movie');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error toggling favorite:', err);
|
|
||||||
// Revert on error
|
|
||||||
if (isFav) {
|
|
||||||
this.favoriteIds.add(favKey);
|
|
||||||
btn.classList.add('active');
|
|
||||||
if (iconSpan) iconSpan.innerHTML = Icons.favorite;
|
|
||||||
} else {
|
|
||||||
this.favoriteIds.delete(favKey);
|
|
||||||
btn.classList.remove('active');
|
|
||||||
if (iconSpan) iconSpan.innerHTML = Icons.favoriteOutline;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.MoviesPage = MoviesPage;
|
|
||||||
@@ -1,502 +0,0 @@
|
|||||||
/**
|
|
||||||
* Series Page Controller
|
|
||||||
* Handles TV series browsing and playback
|
|
||||||
*/
|
|
||||||
|
|
||||||
class SeriesPage {
|
|
||||||
constructor(app) {
|
|
||||||
this.app = app;
|
|
||||||
this.container = document.getElementById('series-grid');
|
|
||||||
this.sourceSelect = document.getElementById('series-source-select');
|
|
||||||
this.categorySelect = document.getElementById('series-category-select');
|
|
||||||
this.searchInput = document.getElementById('series-search');
|
|
||||||
this.detailsPanel = document.getElementById('series-details');
|
|
||||||
this.seasonsContainer = document.getElementById('series-seasons');
|
|
||||||
|
|
||||||
this.seriesList = [];
|
|
||||||
this.categories = [];
|
|
||||||
this.sources = [];
|
|
||||||
this.currentBatch = 0;
|
|
||||||
this.batchSize = 24;
|
|
||||||
this.filteredSeries = [];
|
|
||||||
this.isLoading = false;
|
|
||||||
this.observer = null;
|
|
||||||
this.hiddenCategoryIds = new Set();
|
|
||||||
this.currentSeries = null;
|
|
||||||
this.favoriteIds = new Set(); // Track favorite series IDs
|
|
||||||
this.showFavoritesOnly = false;
|
|
||||||
|
|
||||||
this.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
init() {
|
|
||||||
// Source change handler
|
|
||||||
this.sourceSelect?.addEventListener('change', async () => {
|
|
||||||
await this.loadCategories();
|
|
||||||
await this.loadSeries();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Category change handler
|
|
||||||
this.categorySelect?.addEventListener('change', () => {
|
|
||||||
this.loadSeries();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Search with debounce
|
|
||||||
let searchTimeout;
|
|
||||||
this.searchInput?.addEventListener('input', () => {
|
|
||||||
clearTimeout(searchTimeout);
|
|
||||||
searchTimeout = setTimeout(() => this.filterAndRender(), 300);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Back button
|
|
||||||
document.querySelector('.series-back-btn')?.addEventListener('click', () => {
|
|
||||||
this.hideDetails();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Set up IntersectionObserver for lazy loading
|
|
||||||
this.observer = new IntersectionObserver((entries) => {
|
|
||||||
if (entries[0].isIntersecting && !this.isLoading) {
|
|
||||||
this.renderNextBatch();
|
|
||||||
}
|
|
||||||
}, { rootMargin: '200px' });
|
|
||||||
|
|
||||||
// Favorites filter toggle
|
|
||||||
const favBtn = document.getElementById('series-favorites-btn');
|
|
||||||
favBtn?.addEventListener('click', () => {
|
|
||||||
this.showFavoritesOnly = !this.showFavoritesOnly;
|
|
||||||
favBtn.classList.toggle('active', this.showFavoritesOnly);
|
|
||||||
this.filterAndRender();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async show() {
|
|
||||||
// Hide details panel when showing page
|
|
||||||
this.hideDetails();
|
|
||||||
|
|
||||||
// Load sources if not loaded
|
|
||||||
// Load sources if not loaded
|
|
||||||
if (this.sources.length === 0) {
|
|
||||||
await this.loadSources();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load favorites
|
|
||||||
await this.loadFavorites();
|
|
||||||
|
|
||||||
// Load series if empty
|
|
||||||
if (this.seriesList.length === 0) {
|
|
||||||
await this.loadCategories();
|
|
||||||
await this.loadSeries();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
hide() {
|
|
||||||
// Page is hidden
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadFavorites() {
|
|
||||||
try {
|
|
||||||
const favs = await API.favorites.getAll(null, 'series');
|
|
||||||
this.favoriteIds = new Set(favs.map(f => `${f.source_id}:${f.item_id}`));
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error loading favorites:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadSources() {
|
|
||||||
try {
|
|
||||||
const allSources = await API.sources.getAll();
|
|
||||||
this.sources = allSources.filter(s => s.type === 'xtream' && s.enabled);
|
|
||||||
|
|
||||||
this.sourceSelect.innerHTML = '<option value="">All Sources</option>';
|
|
||||||
this.sources.forEach(s => {
|
|
||||||
const option = document.createElement('option');
|
|
||||||
option.value = s.id;
|
|
||||||
option.textContent = s.name;
|
|
||||||
this.sourceSelect.appendChild(option);
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error loading sources:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadCategories() {
|
|
||||||
try {
|
|
||||||
this.categories = [];
|
|
||||||
this.hiddenCategoryIds = new Set();
|
|
||||||
this.categorySelect.innerHTML = '<option value="">All Categories</option>';
|
|
||||||
|
|
||||||
const sourceId = this.sourceSelect.value;
|
|
||||||
const sourcesToLoad = sourceId
|
|
||||||
? this.sources.filter(s => s.id === parseInt(sourceId))
|
|
||||||
: this.sources;
|
|
||||||
|
|
||||||
// Fetch hidden items for each source
|
|
||||||
for (const source of sourcesToLoad) {
|
|
||||||
try {
|
|
||||||
const hiddenItems = await API.channels.getHidden(source.id);
|
|
||||||
hiddenItems.forEach(h => {
|
|
||||||
if (h.item_type === 'series_category') {
|
|
||||||
this.hiddenCategoryIds.add(`${source.id}:${h.item_id}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(`Failed to load hidden items from source ${source.id}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const source of sourcesToLoad) {
|
|
||||||
try {
|
|
||||||
const cats = await API.proxy.xtream.seriesCategories(source.id);
|
|
||||||
if (cats && Array.isArray(cats)) {
|
|
||||||
cats.forEach(c => {
|
|
||||||
// Skip hidden categories
|
|
||||||
if (!this.hiddenCategoryIds.has(`${source.id}:${c.category_id}`)) {
|
|
||||||
this.categories.push({ ...c, sourceId: source.id });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(`Failed to load series categories from source ${source.id}:`, err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Populate dropdown
|
|
||||||
this.categories.forEach(c => {
|
|
||||||
const option = document.createElement('option');
|
|
||||||
option.value = `${c.sourceId}:${c.category_id}`;
|
|
||||||
option.textContent = c.category_name;
|
|
||||||
this.categorySelect.appendChild(option);
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error loading categories:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadSeries() {
|
|
||||||
this.isLoading = true;
|
|
||||||
this.container.innerHTML = '<div class="loading"><div class="loading-spinner"></div></div>';
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.seriesList = [];
|
|
||||||
|
|
||||||
const sourceId = this.sourceSelect.value;
|
|
||||||
const categoryValue = this.categorySelect.value;
|
|
||||||
|
|
||||||
const sourcesToLoad = sourceId
|
|
||||||
? this.sources.filter(s => s.id === parseInt(sourceId))
|
|
||||||
: this.sources;
|
|
||||||
|
|
||||||
for (const source of sourcesToLoad) {
|
|
||||||
try {
|
|
||||||
// Parse category if selected
|
|
||||||
let catId = null;
|
|
||||||
if (categoryValue) {
|
|
||||||
const [catSourceId, categoryId] = categoryValue.split(':');
|
|
||||||
if (parseInt(catSourceId) === source.id) {
|
|
||||||
catId = categoryId;
|
|
||||||
} else if (sourceId) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const series = await API.proxy.xtream.series(source.id, catId);
|
|
||||||
console.log(`[Series] Source ${source.id}, Category ${catId || 'ALL'}: Got ${series?.length || 0} series`);
|
|
||||||
if (series && Array.isArray(series)) {
|
|
||||||
series.forEach(s => {
|
|
||||||
// Skip series from hidden categories
|
|
||||||
if (this.hiddenCategoryIds.has(`${source.id}:${s.category_id}`)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.seriesList.push({
|
|
||||||
...s,
|
|
||||||
sourceId: source.id,
|
|
||||||
id: `${source.id}:${s.series_id}`
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(`Failed to load series from source ${source.id}:`, err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[Series] Total loaded: ${this.seriesList.length} series`);
|
|
||||||
this.filterAndRender();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error loading series:', err);
|
|
||||||
this.container.innerHTML = '<div class="empty-state"><p>Error loading series</p></div>';
|
|
||||||
} finally {
|
|
||||||
this.isLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
filterAndRender() {
|
|
||||||
const searchTerm = this.searchInput?.value?.toLowerCase() || '';
|
|
||||||
|
|
||||||
this.filteredSeries = this.seriesList.filter(s => {
|
|
||||||
// Filter by favorites if enabled
|
|
||||||
if (this.showFavoritesOnly) {
|
|
||||||
const favKey = `${s.sourceId}:${s.series_id}`;
|
|
||||||
if (!this.favoriteIds.has(favKey)) return false;
|
|
||||||
}
|
|
||||||
if (searchTerm && !s.name?.toLowerCase().includes(searchTerm)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`[Series] Displaying ${this.filteredSeries.length} of ${this.seriesList.length} series`);
|
|
||||||
|
|
||||||
this.currentBatch = 0;
|
|
||||||
this.container.innerHTML = '';
|
|
||||||
|
|
||||||
if (this.filteredSeries.length === 0) {
|
|
||||||
this.container.innerHTML = '<div class="empty-state"><p>No series found</p></div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create loader element
|
|
||||||
const loader = document.createElement('div');
|
|
||||||
loader.className = 'series-loader';
|
|
||||||
loader.innerHTML = '<div class="loading-spinner"></div>';
|
|
||||||
this.container.appendChild(loader);
|
|
||||||
|
|
||||||
// Render initial batches
|
|
||||||
for (let i = 0; i < 5; i++) {
|
|
||||||
this.renderNextBatch();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start observing loader
|
|
||||||
this.observer.observe(loader);
|
|
||||||
}
|
|
||||||
|
|
||||||
renderNextBatch() {
|
|
||||||
const start = this.currentBatch * this.batchSize;
|
|
||||||
const end = start + this.batchSize;
|
|
||||||
const batch = this.filteredSeries.slice(start, end);
|
|
||||||
|
|
||||||
if (batch.length === 0) {
|
|
||||||
const loader = this.container.querySelector('.series-loader');
|
|
||||||
if (loader) loader.style.display = 'none';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fragment = document.createDocumentFragment();
|
|
||||||
|
|
||||||
batch.forEach(series => {
|
|
||||||
const card = document.createElement('div');
|
|
||||||
card.className = 'series-card';
|
|
||||||
card.dataset.seriesId = series.series_id;
|
|
||||||
card.dataset.sourceId = series.sourceId;
|
|
||||||
|
|
||||||
const poster = series.cover || '/img/placeholder.png';
|
|
||||||
const year = series.year || series.releaseDate?.substring(0, 4) || '';
|
|
||||||
const rating = series.rating ? `${Icons.star} ${series.rating}` : '';
|
|
||||||
|
|
||||||
const isFav = this.favoriteIds.has(`${series.sourceId}:${series.series_id}`);
|
|
||||||
|
|
||||||
card.innerHTML = `
|
|
||||||
<div class="series-poster">
|
|
||||||
<img src="${poster}" alt="${series.name}"
|
|
||||||
onerror="this.onerror=null;this.src='/img/placeholder.png'" loading="lazy">
|
|
||||||
<div class="series-play-overlay">
|
|
||||||
<span class="play-icon">${Icons.play}</span>
|
|
||||||
</div>
|
|
||||||
<button class="favorite-btn ${isFav ? 'active' : ''}" title="${isFav ? 'Remove from Favorites' : 'Add to Favorites'}">
|
|
||||||
<span class="fav-icon">${isFav ? Icons.favorite : Icons.favoriteOutline}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="series-card-info">
|
|
||||||
<div class="series-title">${series.name}</div>
|
|
||||||
<div class="series-meta">
|
|
||||||
${year ? `<span>${year}</span>` : ''}
|
|
||||||
${rating ? `<span>${rating}</span>` : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
card.addEventListener('click', (e) => {
|
|
||||||
if (e.target.closest('.favorite-btn')) {
|
|
||||||
const btn = e.target.closest('.favorite-btn');
|
|
||||||
this.toggleFavorite(series, btn);
|
|
||||||
e.stopPropagation();
|
|
||||||
} else {
|
|
||||||
this.showSeriesDetails(series);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
fragment.appendChild(card);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Insert before loader
|
|
||||||
const loader = this.container.querySelector('.series-loader');
|
|
||||||
if (loader) {
|
|
||||||
this.container.insertBefore(fragment, loader);
|
|
||||||
} else {
|
|
||||||
this.container.appendChild(fragment);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.currentBatch++;
|
|
||||||
|
|
||||||
// Hide loader if done
|
|
||||||
if (end >= this.filteredSeries.length && loader) {
|
|
||||||
loader.style.display = 'none';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async showSeriesDetails(series) {
|
|
||||||
this.currentSeries = series;
|
|
||||||
|
|
||||||
// Show details panel
|
|
||||||
this.container.classList.add('hidden');
|
|
||||||
this.detailsPanel.classList.remove('hidden');
|
|
||||||
|
|
||||||
// Set header info
|
|
||||||
document.getElementById('series-poster').src = series.cover || '/img/placeholder.png';
|
|
||||||
document.getElementById('series-title').textContent = series.name;
|
|
||||||
document.getElementById('series-plot').textContent = series.plot || '';
|
|
||||||
|
|
||||||
// Show loading
|
|
||||||
this.seasonsContainer.innerHTML = '<div class="loading"><div class="loading-spinner"></div></div>';
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Fetch series info (seasons/episodes)
|
|
||||||
const info = await API.proxy.xtream.seriesInfo(series.sourceId, series.series_id);
|
|
||||||
|
|
||||||
if (!info || !info.episodes) {
|
|
||||||
this.seasonsContainer.innerHTML = '<p class="hint">No episodes found</p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store series info for WatchPage
|
|
||||||
this.currentSeriesInfo = info;
|
|
||||||
|
|
||||||
// Render seasons and episodes
|
|
||||||
let html = '';
|
|
||||||
const seasons = Object.keys(info.episodes).sort((a, b) => parseInt(a) - parseInt(b));
|
|
||||||
|
|
||||||
seasons.forEach(seasonNum => {
|
|
||||||
const episodes = info.episodes[seasonNum];
|
|
||||||
html += `
|
|
||||||
<div class="season-group">
|
|
||||||
<div class="season-header">
|
|
||||||
<span class="season-expander">${Icons.chevronDown}</span>
|
|
||||||
<span class="season-name">Season ${seasonNum} (${episodes.length} episodes)</span>
|
|
||||||
</div>
|
|
||||||
<div class="episode-list">
|
|
||||||
${episodes.map(ep => `
|
|
||||||
<div class="episode-item" data-episode-id="${ep.id}" data-source-id="${series.sourceId}" data-container="${ep.container_extension || 'mp4'}">
|
|
||||||
<span class="episode-number">E${ep.episode_num}</span>
|
|
||||||
<span class="episode-title">${ep.title || `Episode ${ep.episode_num}`}</span>
|
|
||||||
<span class="episode-duration">${ep.duration || ''}</span>
|
|
||||||
</div>
|
|
||||||
`).join('')}
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
});
|
|
||||||
|
|
||||||
this.seasonsContainer.innerHTML = html;
|
|
||||||
|
|
||||||
// Add click handlers
|
|
||||||
this.seasonsContainer.querySelectorAll('.season-header').forEach(header => {
|
|
||||||
header.addEventListener('click', () => {
|
|
||||||
header.closest('.season-group').classList.toggle('collapsed');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
this.seasonsContainer.querySelectorAll('.episode-item').forEach(ep => {
|
|
||||||
ep.addEventListener('click', () => this.playEpisode(ep));
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error loading series info:', err);
|
|
||||||
this.seasonsContainer.innerHTML = '<p class="hint" style="color: var(--color-error);">Error loading episodes</p>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
hideDetails() {
|
|
||||||
this.detailsPanel.classList.add('hidden');
|
|
||||||
this.container.classList.remove('hidden');
|
|
||||||
this.currentSeries = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async playEpisode(episodeEl) {
|
|
||||||
const episodeId = episodeEl.dataset.episodeId;
|
|
||||||
const sourceId = parseInt(episodeEl.dataset.sourceId);
|
|
||||||
const container = episodeEl.dataset.container || 'mp4';
|
|
||||||
|
|
||||||
// Get season and episode number from the episode element context
|
|
||||||
const seasonGroup = episodeEl.closest('.season-group');
|
|
||||||
const seasonHeader = seasonGroup?.querySelector('.season-name')?.textContent || '';
|
|
||||||
const seasonMatch = seasonHeader.match(/Season (\d+)/);
|
|
||||||
const seasonNum = seasonMatch ? seasonMatch[1] : '1';
|
|
||||||
const episodeNum = episodeEl.querySelector('.episode-number')?.textContent?.replace('E', '') || '1';
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Get stream URL for episode (use 'series' type)
|
|
||||||
const result = await API.proxy.xtream.getStreamUrl(sourceId, episodeId, 'series', container);
|
|
||||||
|
|
||||||
if (result && result.url) {
|
|
||||||
// Play in dedicated Watch page
|
|
||||||
if (this.app.pages.watch) {
|
|
||||||
const episodeTitle = episodeEl.querySelector('.episode-title')?.textContent || `Episode ${episodeNum}`;
|
|
||||||
|
|
||||||
this.app.pages.watch.play({
|
|
||||||
type: 'series',
|
|
||||||
id: episodeId,
|
|
||||||
title: this.currentSeries?.name || 'Series',
|
|
||||||
subtitle: `S${seasonNum} E${episodeNum} - ${episodeTitle}`,
|
|
||||||
poster: this.currentSeries?.cover,
|
|
||||||
description: this.currentSeries?.plot || '',
|
|
||||||
year: this.currentSeries?.year,
|
|
||||||
rating: this.currentSeries?.rating,
|
|
||||||
sourceId: sourceId,
|
|
||||||
seriesId: this.currentSeries?.series_id,
|
|
||||||
seriesInfo: this.currentSeriesInfo,
|
|
||||||
currentSeason: seasonNum,
|
|
||||||
currentEpisode: episodeNum,
|
|
||||||
durationSec: episodeEl.querySelector('.episode-duration')?.textContent || '',
|
|
||||||
containerExtension: container
|
|
||||||
}, result.url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error playing episode:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async toggleFavorite(series, btn) {
|
|
||||||
const favKey = `${series.sourceId}:${series.series_id}`;
|
|
||||||
const isFav = this.favoriteIds.has(favKey);
|
|
||||||
const iconSpan = btn.querySelector('.fav-icon');
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Optimistic update
|
|
||||||
if (isFav) {
|
|
||||||
this.favoriteIds.delete(favKey);
|
|
||||||
btn.classList.remove('active');
|
|
||||||
btn.title = 'Add to Favorites';
|
|
||||||
if (iconSpan) iconSpan.innerHTML = Icons.favoriteOutline;
|
|
||||||
await API.favorites.remove(series.sourceId, series.series_id, 'series');
|
|
||||||
} else {
|
|
||||||
this.favoriteIds.add(favKey);
|
|
||||||
btn.classList.add('active');
|
|
||||||
btn.title = 'Remove from Favorites';
|
|
||||||
if (iconSpan) iconSpan.innerHTML = Icons.favorite;
|
|
||||||
await API.favorites.add(series.sourceId, series.series_id, 'series');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error toggling favorite:', err);
|
|
||||||
// Revert on error
|
|
||||||
if (isFav) {
|
|
||||||
this.favoriteIds.add(favKey);
|
|
||||||
btn.classList.add('active');
|
|
||||||
if (iconSpan) iconSpan.innerHTML = Icons.favorite;
|
|
||||||
} else {
|
|
||||||
this.favoriteIds.delete(favKey);
|
|
||||||
btn.classList.remove('active');
|
|
||||||
if (iconSpan) iconSpan.innerHTML = Icons.favoriteOutline;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.SeriesPage = SeriesPage;
|
|
||||||
File diff suppressed because it is too large
Load Diff
+140
-175
@@ -6,160 +6,139 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Iniciar sesión — KiraTV</title>
|
<title>Iniciar sesión — KiraTV</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="stylesheet" href="css/main.css">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,600;0,9..40,700&family=Montserrat:wght@600;700;800&display=swap" rel="stylesheet">
|
|
||||||
<style>
|
<style>
|
||||||
:root {
|
|
||||||
--navy: #000066;
|
|
||||||
--navy-mid: #000044;
|
|
||||||
--gold: #e8d5b5;
|
|
||||||
--gold-hover: #f5ead8;
|
|
||||||
--text: #ffffff;
|
|
||||||
--muted: rgba(255, 255, 255, 0.72);
|
|
||||||
--border: rgba(255, 255, 255, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
min-height: 100vh;
|
padding: 0;
|
||||||
font-family: 'DM Sans', system-ui, sans-serif;
|
background: var(--color-bg-primary);
|
||||||
color: var(--text);
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
background:
|
|
||||||
radial-gradient(ellipse 100% 80% at 50% -30%, rgba(255, 255, 255, 0.06), transparent 50%),
|
|
||||||
radial-gradient(ellipse 70% 50% at 110% 40%, rgba(0, 0, 102, 0.55), transparent),
|
|
||||||
linear-gradient(165deg, var(--navy) 0%, var(--navy-mid) 100%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-wrap {
|
.login-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
padding: 24px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-card {
|
.login-box {
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
padding: var(--space-2xl);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 440px;
|
max-width: 420px;
|
||||||
padding: 2.25rem 2rem;
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 18px;
|
|
||||||
background: rgba(0, 0, 70, 0.55);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
box-shadow:
|
|
||||||
0 24px 48px rgba(0, 0, 0, 0.45),
|
|
||||||
0 0 0 1px rgba(255, 255, 255, 0.03) inset;
|
|
||||||
backdrop-filter: blur(14px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-block {
|
.login-logo {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-bottom: 2rem;
|
margin-bottom: var(--space-2xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-brand-img {
|
.login-logo .logo-icon {
|
||||||
display: block;
|
width: 64px;
|
||||||
margin: 0 auto;
|
height: 64px;
|
||||||
max-width: min(300px, 100%);
|
margin: 0 auto var(--space-md);
|
||||||
height: auto;
|
color: var(--color-accent);
|
||||||
max-height: 220px;
|
|
||||||
object-fit: contain;
|
|
||||||
border-radius: 16px;
|
|
||||||
filter: drop-shadow(0 16px 40px rgba(0, 0, 0, 0.45));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-tagline {
|
.login-logo h1 {
|
||||||
margin: 0.75rem 0 0;
|
font-family: 'Century Gothic', sans-serif;
|
||||||
font-size: 0.7rem;
|
font-size: 28px;
|
||||||
font-weight: 700;
|
font-weight: 500;
|
||||||
letter-spacing: 0.35em;
|
color: var(--color-text-primary);
|
||||||
text-transform: uppercase;
|
margin: 0;
|
||||||
color: var(--gold);
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#login-subtitle {
|
.login-logo .brand-accent {
|
||||||
margin: 0.5rem 0 0;
|
color: var(--color-accent);
|
||||||
font-size: 0.95rem;
|
}
|
||||||
color: var(--muted);
|
|
||||||
|
.login-logo p {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin: var(--space-sm) 0 0 0;
|
||||||
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group {
|
.form-group {
|
||||||
margin-bottom: 1.1rem;
|
margin-bottom: var(--space-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group label {
|
.form-group label {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 0.35rem;
|
margin-bottom: var(--space-sm);
|
||||||
font-size: 0.875rem;
|
color: var(--color-text-primary);
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
color: var(--text);
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group input {
|
.form-group input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 12px 14px;
|
padding: 12px 16px;
|
||||||
border-radius: 10px;
|
background: var(--color-bg-tertiary);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
border: 1px solid var(--color-border);
|
||||||
background: rgba(8, 12, 20, 0.65);
|
border-radius: var(--radius-md);
|
||||||
color: var(--text);
|
color: var(--color-text-primary);
|
||||||
font-size: 1rem;
|
font-size: 15px;
|
||||||
font-family: inherit;
|
font-family: 'Outfit', sans-serif;
|
||||||
transition: border-color 0.2s, box-shadow 0.2s;
|
transition: all 0.2s;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group input:focus {
|
.form-group input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: rgba(232, 213, 181, 0.5);
|
border-color: var(--color-accent);
|
||||||
box-shadow: 0 0 0 3px rgba(232, 213, 181, 0.1);
|
background: var(--color-bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group input::placeholder {
|
.form-group input::placeholder {
|
||||||
color: rgba(168, 176, 196, 0.55);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-login {
|
.btn-login {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 0.25rem;
|
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 12px;
|
border-radius: var(--radius-md);
|
||||||
font-size: 1rem;
|
font-size: 16px;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
font-family: 'Montserrat', sans-serif;
|
font-family: 'Outfit', sans-serif;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: linear-gradient(135deg, var(--gold) 0%, #a67c1f 100%);
|
transition: all 0.2s;
|
||||||
color: var(--navy);
|
|
||||||
transition: transform 0.15s, box-shadow 0.2s;
|
|
||||||
box-shadow: 0 4px 20px rgba(232, 213, 181, 0.18);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-login:hover:not(:disabled) {
|
.btn-login:hover {
|
||||||
background: linear-gradient(135deg, var(--gold-hover) 0%, var(--gold) 100%);
|
background: var(--color-accent-hover);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
box-shadow: 0 8px 28px rgba(232, 213, 181, 0.22);
|
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-login:active {
|
||||||
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-login:disabled {
|
.btn-login:disabled {
|
||||||
opacity: 0.55;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
transform: none;
|
transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-message {
|
.error-message {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
color: var(--color-error);
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||||
|
font-size: 14px;
|
||||||
display: none;
|
display: none;
|
||||||
margin-bottom: 1rem;
|
|
||||||
padding: 12px 14px;
|
|
||||||
border-radius: 10px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
background: rgba(248, 113, 113, 0.12);
|
|
||||||
border: 1px solid rgba(248, 113, 113, 0.35);
|
|
||||||
color: #fecaca;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-message.show {
|
.error-message.show {
|
||||||
@@ -167,77 +146,32 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.setup-message {
|
.setup-message {
|
||||||
|
background: rgba(99, 102, 241, 0.1);
|
||||||
|
color: var(--color-accent);
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
border: 1px solid var(--color-accent-dim);
|
||||||
|
font-size: 14px;
|
||||||
display: none;
|
display: none;
|
||||||
margin-bottom: 1rem;
|
|
||||||
padding: 12px 14px;
|
|
||||||
border-radius: 10px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
background: rgba(232, 213, 181, 0.08);
|
|
||||||
border: 1px solid rgba(232, 213, 181, 0.22);
|
|
||||||
color: var(--gold-hover);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.setup-message.show {
|
.setup-message.show {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sso-divider {
|
|
||||||
text-align: center;
|
|
||||||
margin: 1.35rem 0;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sso-divider span {
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
padding: 0 12px;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--muted);
|
|
||||||
background: rgba(15, 22, 38, 0.92);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sso-divider::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
top: 50%;
|
|
||||||
height: 1px;
|
|
||||||
background: rgba(255, 255, 255, 0.08);
|
|
||||||
z-index: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#btn-sso-login {
|
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 12px;
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text);
|
|
||||||
font-size: 0.95rem;
|
|
||||||
font-family: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.2s, border-color 0.2s, color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
#btn-sso-login:hover {
|
|
||||||
background: rgba(232, 213, 181, 0.06);
|
|
||||||
border-color: rgba(232, 213, 181, 0.35);
|
|
||||||
color: var(--gold-hover);
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div class="login-wrap">
|
<div class="login-container">
|
||||||
<div class="login-card">
|
<div class="login-box">
|
||||||
<div class="brand-block">
|
<div class="login-logo">
|
||||||
<img src="/img/kiratv-logo-hero.jpg" alt="KiraTV — Plataforma IPTV" width="320" height="320" decoding="async" class="login-brand-img">
|
<svg class="logo-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 187.63 210.6"
|
||||||
<p class="brand-tagline">Plataforma IPTV</p>
|
fill="currentColor">
|
||||||
|
<path
|
||||||
|
d="M169.71,87.87c-4.77,0-9.1,1.88-12.31,4.92L35.3,22.3c.35-1.4.56-2.86.56-4.37C35.86,8.03,27.83,0,17.93,0S0,8.03,0,17.93c0,8.44,5.84,15.5,13.69,17.4v140c-7.71,2.01-13.41,8.99-13.41,17.33,0,9.9,8.03,17.93,17.93,17.93s17.93-8.03,17.93-17.93c0-1.56-.22-3.07-.6-4.51l121.21-69.98c3.26,3.42,7.86,5.55,12.95,5.55,9.9,0,17.93-8.03,17.93-17.93s-8.03-17.93-17.93-17.93ZM152.78,99.92c-.14.41-.27.83-.38,1.25h-51.47l-44.18-24.72L30.4,30.8c.33-.32.64-.65.94-.99l121.45,70.12ZM22.17,175.2V35.33c.23-.05.45-.11.67-.17l26.35,45.65-.34,50.04-25.74,44.59c-.31-.09-.63-.17-.95-.24ZM31.52,180.69c-.27-.3-.55-.6-.84-.89l25.48-44.13,44.64-25.78h51.47c.07.32.15.63.24.94L31.52,180.69Z" />
|
||||||
|
</svg>
|
||||||
|
<h1>KiraTV</h1>
|
||||||
<p id="login-subtitle">Inicia sesión para continuar</p>
|
<p id="login-subtitle">Inicia sesión para continuar</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -248,47 +182,67 @@
|
|||||||
|
|
||||||
<form id="login-form">
|
<form id="login-form">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="username">Usuario</label>
|
<label for="username">Username</label>
|
||||||
<input type="text" id="username" name="username" required autocomplete="username"
|
<input type="text" id="username" name="username" required autocomplete="username"
|
||||||
placeholder="Tu nombre de usuario">
|
placeholder="Enter your username">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="password">Contraseña</label>
|
<label for="password">Password</label>
|
||||||
<input type="password" id="password" name="password" required autocomplete="current-password"
|
<input type="password" id="password" name="password" required autocomplete="current-password"
|
||||||
minlength="6" placeholder="Tu contraseña">
|
minlength="6" placeholder="Enter your password">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="btn-login" id="submit-btn">Entrar</button>
|
<button type="submit" class="btn-login" id="submit-btn">Sign In</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="sso-divider">
|
<div class="sso-divider" style="text-align: center; margin: 20px 0; position: relative;">
|
||||||
<span>o</span>
|
<span
|
||||||
|
style="background: var(--color-bg-secondary); padding: 0 10px; color: var(--color-text-secondary); font-size: 14px; position: relative; z-index: 1;">OR</span>
|
||||||
|
<div
|
||||||
|
style="position: absolute; top: 50%; left: 0; right: 0; height: 1px; background: var(--color-border); z-index: 0;">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="button" id="btn-sso-login">
|
<button id="btn-sso-login" class="btn-login"
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" style="width:20px;height:20px;">
|
style="background: transparent; border: 1px solid var(--color-border); color: var(--color-text-primary); display: flex; align-items: center; justify-content: center; gap: 10px; transition: all 0.2s;">
|
||||||
<path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm-2 16l-4-4 1.41-1.41L10 14.17l6.59-6.59L18 9l-8 8z" />
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
|
||||||
|
style="width: 20px; height: 20px;">
|
||||||
|
<path
|
||||||
|
d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm-2 16l-4-4 1.41-1.41L10 14.17l6.59-6.59L18 9l-8 8z" />
|
||||||
</svg>
|
</svg>
|
||||||
Entrar con SSO
|
Sign in with SSO
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
#btn-sso-login:hover {
|
||||||
|
background: var(--color-bg-tertiary) !important;
|
||||||
|
border-color: var(--color-accent) !important;
|
||||||
|
color: var(--color-accent) !important;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
// Start SSO Login
|
||||||
document.getElementById('btn-sso-login').addEventListener('click', () => {
|
document.getElementById('btn-sso-login').addEventListener('click', () => {
|
||||||
window.location.href = '/api/auth/oidc/login';
|
window.location.href = '/api/auth/oidc/login';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Check for URL Error params
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
const error = urlParams.get('error');
|
const error = urlParams.get('error');
|
||||||
if (error) {
|
if (error) {
|
||||||
const errorMessage = document.getElementById('error-message');
|
const errorMessage = document.getElementById('error-message');
|
||||||
errorMessage.textContent = error;
|
errorMessage.textContent = error;
|
||||||
errorMessage.classList.add('show');
|
errorMessage.classList.add('show');
|
||||||
|
// Clean URL
|
||||||
window.history.replaceState({}, document.title, window.location.pathname);
|
window.history.replaceState({}, document.title, window.location.pathname);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if setup is required
|
||||||
let isSetupMode = false;
|
let isSetupMode = false;
|
||||||
|
|
||||||
async function checkSetupRequired() {
|
async function checkSetupRequired() {
|
||||||
@@ -298,10 +252,11 @@
|
|||||||
|
|
||||||
if (data.setupRequired) {
|
if (data.setupRequired) {
|
||||||
isSetupMode = true;
|
isSetupMode = true;
|
||||||
document.getElementById('login-subtitle').textContent = 'Crea la cuenta de administrador';
|
document.getElementById('login-subtitle').textContent = 'Create Admin Account';
|
||||||
document.getElementById('submit-btn').textContent = 'Crear cuenta';
|
document.getElementById('submit-btn').textContent = 'Create Account';
|
||||||
document.getElementById('setup-message').classList.add('show');
|
document.getElementById('setup-message').classList.add('show');
|
||||||
document.getElementById('password').placeholder = 'Mínimo 6 caracteres';
|
document.getElementById('password').placeholder = 'Minimum 6 characters';
|
||||||
|
// Hide SSO in setup mode
|
||||||
document.querySelector('.sso-divider').style.display = 'none';
|
document.querySelector('.sso-divider').style.display = 'none';
|
||||||
document.getElementById('btn-sso-login').style.display = 'none';
|
document.getElementById('btn-sso-login').style.display = 'none';
|
||||||
}
|
}
|
||||||
@@ -310,6 +265,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle form submission
|
||||||
document.getElementById('login-form').addEventListener('submit', async (e) => {
|
document.getElementById('login-form').addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
@@ -318,38 +274,47 @@
|
|||||||
const submitBtn = document.getElementById('submit-btn');
|
const submitBtn = document.getElementById('submit-btn');
|
||||||
const errorMessage = document.getElementById('error-message');
|
const errorMessage = document.getElementById('error-message');
|
||||||
|
|
||||||
|
// Clear previous errors
|
||||||
errorMessage.classList.remove('show');
|
errorMessage.classList.remove('show');
|
||||||
errorMessage.textContent = '';
|
errorMessage.textContent = '';
|
||||||
|
|
||||||
|
// Disable button
|
||||||
submitBtn.disabled = true;
|
submitBtn.disabled = true;
|
||||||
submitBtn.textContent = 'Espera…';
|
submitBtn.textContent = 'Please wait...';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const endpoint = isSetupMode ? '/api/auth/setup' : '/api/auth/login';
|
const endpoint = isSetupMode ? '/api/auth/setup' : '/api/auth/login';
|
||||||
const response = await fetch(endpoint, {
|
const response = await fetch(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
body: JSON.stringify({ username, password })
|
body: JSON.stringify({ username, password })
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(data.error || 'Error de autenticación');
|
throw new Error(data.error || 'Authentication failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Store token
|
||||||
localStorage.setItem('authToken', data.token);
|
localStorage.setItem('authToken', data.token);
|
||||||
|
|
||||||
|
// Redirect to main app (replace history to prevent back button issues)
|
||||||
window.location.replace('/');
|
window.location.replace('/');
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errorMessage.textContent = err.message;
|
errorMessage.textContent = err.message;
|
||||||
errorMessage.classList.add('show');
|
errorMessage.classList.add('show');
|
||||||
submitBtn.disabled = false;
|
submitBtn.disabled = false;
|
||||||
submitBtn.textContent = isSetupMode ? 'Crear cuenta' : 'Entrar';
|
submitBtn.textContent = isSetupMode ? 'Create Account' : 'Sign In';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Check setup status on load
|
||||||
checkSetupRequired();
|
checkSetupRequired();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
+3
-11
@@ -100,7 +100,7 @@ function configureJwtStrategy(getUserById) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return done(null, {
|
return done(null, {
|
||||||
id: Number(user.id),
|
id: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
role: user.role
|
role: user.role
|
||||||
});
|
});
|
||||||
@@ -230,18 +230,11 @@ function configureOidcStrategy(findUserByOidcId, findUserByEmail, createUser) {
|
|||||||
*/
|
*/
|
||||||
const requireAuth = passport.authenticate('jwt', { session: false });
|
const requireAuth = passport.authenticate('jwt', { session: false });
|
||||||
|
|
||||||
/**
|
|
||||||
* True if user has admin role (case-insensitive, tolerates stray whitespace).
|
|
||||||
*/
|
|
||||||
function isAdminRole(user) {
|
|
||||||
return !!(user && String(user.role || '').trim().toLowerCase() === 'admin');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Middleware: Require admin role
|
* Middleware: Require admin role
|
||||||
*/
|
*/
|
||||||
function requireAdmin(req, res, next) {
|
function requireAdmin(req, res, next) {
|
||||||
if (!isAdminRole(req.user)) {
|
if (!req.user || req.user.role !== 'admin') {
|
||||||
return res.status(403).json({ error: 'Forbidden - Admin access required' });
|
return res.status(403).json({ error: 'Forbidden - Admin access required' });
|
||||||
}
|
}
|
||||||
next();
|
next();
|
||||||
@@ -271,6 +264,5 @@ module.exports = {
|
|||||||
configureOidcStrategy,
|
configureOidcStrategy,
|
||||||
requireAuth,
|
requireAuth,
|
||||||
requireAdmin,
|
requireAdmin,
|
||||||
requireRole,
|
requireRole
|
||||||
isAdminRole
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,268 +0,0 @@
|
|||||||
const bcrypt = require('bcryptjs');
|
|
||||||
const jwt = require('jsonwebtoken');
|
|
||||||
const passport = require('passport');
|
|
||||||
const { Strategy: JwtStrategy, ExtractJwt } = require('passport-jwt');
|
|
||||||
const { Strategy: LocalStrategy } = require('passport-local');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Authentication and Authorization Module
|
|
||||||
* Handles user authentication, session management, and role-based access control
|
|
||||||
* Using Passport.js with JWT tokens
|
|
||||||
*/
|
|
||||||
|
|
||||||
// JWT Secret - In production, use environment variable
|
|
||||||
const JWT_SECRET = process.env.JWT_SECRET || 'nodecast-tv-secret-key-change-in-production';
|
|
||||||
const JWT_EXPIRY = '24h';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hash password using bcrypt
|
|
||||||
*/
|
|
||||||
async function hashPassword(password) {
|
|
||||||
const salt = await bcrypt.genSalt(10);
|
|
||||||
return bcrypt.hash(password, salt);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify password against hash
|
|
||||||
*/
|
|
||||||
async function verifyPassword(password, hash) {
|
|
||||||
return bcrypt.compare(password, hash);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate JWT token
|
|
||||||
*/
|
|
||||||
function generateToken(user) {
|
|
||||||
return jwt.sign(
|
|
||||||
{
|
|
||||||
id: user.id,
|
|
||||||
username: user.username,
|
|
||||||
role: user.role
|
|
||||||
},
|
|
||||||
JWT_SECRET,
|
|
||||||
{ expiresIn: JWT_EXPIRY }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify JWT token
|
|
||||||
*/
|
|
||||||
function verifyToken(token) {
|
|
||||||
try {
|
|
||||||
return jwt.verify(token, JWT_SECRET);
|
|
||||||
} catch (err) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Configure Passport Local Strategy for username/password authentication
|
|
||||||
*/
|
|
||||||
function configureLocalStrategy(getUserByUsername, verifyUserPassword) {
|
|
||||||
passport.use(new LocalStrategy(
|
|
||||||
async (username, password, done) => {
|
|
||||||
try {
|
|
||||||
const user = await getUserByUsername(username);
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return done(null, false, { message: 'Invalid credentials' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const isValid = await verifyUserPassword(password, user.passwordHash);
|
|
||||||
|
|
||||||
if (!isValid) {
|
|
||||||
return done(null, false, { message: 'Invalid credentials' });
|
|
||||||
}
|
|
||||||
|
|
||||||
return done(null, user);
|
|
||||||
} catch (err) {
|
|
||||||
return done(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Configure Passport JWT Strategy for token-based authentication
|
|
||||||
*/
|
|
||||||
function configureJwtStrategy(getUserById) {
|
|
||||||
const options = {
|
|
||||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
|
||||||
secretOrKey: JWT_SECRET
|
|
||||||
};
|
|
||||||
|
|
||||||
passport.use(new JwtStrategy(options, async (payload, done) => {
|
|
||||||
try {
|
|
||||||
const user = await getUserById(payload.id);
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return done(null, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
return done(null, {
|
|
||||||
id: user.id,
|
|
||||||
username: user.username,
|
|
||||||
role: user.role
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
return done(err, false);
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Configure Passport session serialization
|
|
||||||
* Required for OIDC flow which uses sessions
|
|
||||||
*/
|
|
||||||
function configureSessionSerialization(getUserById) {
|
|
||||||
passport.serializeUser((user, done) => {
|
|
||||||
done(null, user.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
passport.deserializeUser(async (id, done) => {
|
|
||||||
try {
|
|
||||||
const user = await getUserById(id);
|
|
||||||
done(null, user);
|
|
||||||
} catch (err) {
|
|
||||||
done(err, null);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Configure Passport OpenID Connect Strategy
|
|
||||||
*/
|
|
||||||
function configureOidcStrategy(findUserByOidcId, findUserByEmail, createUser) {
|
|
||||||
if (!process.env.OIDC_ISSUER_URL || !process.env.OIDC_CLIENT_ID || !process.env.OIDC_CLIENT_SECRET) {
|
|
||||||
console.warn('OIDC configuration missing - SSO disabled');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { Strategy: OpenIDConnectStrategy } = require('passport-openidconnect');
|
|
||||||
|
|
||||||
passport.use(new OpenIDConnectStrategy({
|
|
||||||
issuer: process.env.OIDC_ISSUER_URL || 'https://mock-issuer.com', // Dummy default for mock
|
|
||||||
authorizationURL: process.env.OIDC_AUTH_URL || `${process.env.OIDC_ISSUER_URL}/protocol/openid-connect/auth`,
|
|
||||||
tokenURL: process.env.OIDC_TOKEN_URL || `${process.env.OIDC_ISSUER_URL}/protocol/openid-connect/token`,
|
|
||||||
userInfoURL: process.env.OIDC_USERINFO_URL || `${process.env.OIDC_ISSUER_URL}/protocol/openid-connect/userinfo`,
|
|
||||||
clientID: process.env.OIDC_CLIENT_ID || 'mock-client-id',
|
|
||||||
clientSecret: process.env.OIDC_CLIENT_SECRET || 'mock-secret',
|
|
||||||
callbackURL: process.env.OIDC_CALLBACK_URL || '/api/auth/oidc/callback',
|
|
||||||
scope: ['openid', 'profile', 'email']
|
|
||||||
},
|
|
||||||
async (...args) => {
|
|
||||||
// The done callback is always the last argument
|
|
||||||
const done = args[args.length - 1];
|
|
||||||
|
|
||||||
// Map known arguments
|
|
||||||
// Standard: issuer, sub, profile, accessToken, refreshToken, done
|
|
||||||
// Some versions: issuer, sub, profile, accessToken, refreshToken, params, done
|
|
||||||
|
|
||||||
let issuer, sub, profile;
|
|
||||||
|
|
||||||
if (args.length === 3) {
|
|
||||||
// Scenario: (issuer, profile, done)
|
|
||||||
const arg0 = args[0];
|
|
||||||
const arg1 = args[1];
|
|
||||||
|
|
||||||
if (typeof arg1 === 'object' && arg1.id) {
|
|
||||||
issuer = arg0;
|
|
||||||
profile = arg1;
|
|
||||||
sub = profile.id;
|
|
||||||
} else if (typeof arg0 === 'string' && typeof arg1 === 'string') {
|
|
||||||
issuer = arg0;
|
|
||||||
sub = arg1;
|
|
||||||
profile = { id: sub, displayName: 'Unknown' };
|
|
||||||
}
|
|
||||||
} else if (args.length >= 4) {
|
|
||||||
// Assume standard: iss, sub, profile...
|
|
||||||
issuer = args[0];
|
|
||||||
sub = args[1];
|
|
||||||
profile = args[2];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!sub && profile && profile.id) sub = profile.id;
|
|
||||||
|
|
||||||
if (!sub) {
|
|
||||||
return done(new Error('Could not identify OIDC Subject (sub) from arguments'));
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 1. Try to find by OIDC ID (sub)
|
|
||||||
let user = await findUserByOidcId(sub);
|
|
||||||
|
|
||||||
// 2. If not found, try to match by email
|
|
||||||
// Extract email - handle both profile.emails[] (Google) and profile.email (others)
|
|
||||||
const email = profile.emails?.[0]?.value || profile.email || profile._json?.email;
|
|
||||||
|
|
||||||
if (!user && email) {
|
|
||||||
user = await findUserByEmail(email);
|
|
||||||
|
|
||||||
// If found by email but no OIDC ID, link them
|
|
||||||
if (user && !user.oidcId) {
|
|
||||||
// We don't have a direct update method for specific fields without full user object in this context
|
|
||||||
// Ideally we'd update the user here. For now, we'll just log in.
|
|
||||||
// Future: Update user with oidcId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. If still not found, create new user (JIT Provisioning)
|
|
||||||
if (!user) {
|
|
||||||
const username = profile.username || profile.displayName || (email ? email.split('@')[0] : `user_${sub.substring(0, 8)}`);
|
|
||||||
|
|
||||||
user = await createUser({
|
|
||||||
username: username,
|
|
||||||
role: 'viewer', // Default role for SSO users
|
|
||||||
oidcId: sub,
|
|
||||||
email: email || null
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return done(null, user);
|
|
||||||
} catch (err) {
|
|
||||||
return done(err);
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Middleware: Require authentication using Passport JWT
|
|
||||||
*/
|
|
||||||
const requireAuth = passport.authenticate('jwt', { session: false });
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Middleware: Require admin role
|
|
||||||
*/
|
|
||||||
function requireAdmin(req, res, next) {
|
|
||||||
if (!req.user || req.user.role !== 'admin') {
|
|
||||||
return res.status(403).json({ error: 'Forbidden - Admin access required' });
|
|
||||||
}
|
|
||||||
next();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Middleware: Check for specific role
|
|
||||||
*/
|
|
||||||
function requireRole(role) {
|
|
||||||
return (req, res, next) => {
|
|
||||||
if (!req.user || req.user.role !== role) {
|
|
||||||
return res.status(403).json({ error: `Forbidden - ${role} access required` });
|
|
||||||
}
|
|
||||||
next();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
passport,
|
|
||||||
hashPassword,
|
|
||||||
verifyPassword,
|
|
||||||
generateToken,
|
|
||||||
verifyToken,
|
|
||||||
configureLocalStrategy,
|
|
||||||
configureJwtStrategy,
|
|
||||||
configureSessionSerialization,
|
|
||||||
configureOidcStrategy,
|
|
||||||
requireAuth,
|
|
||||||
requireAdmin,
|
|
||||||
requireRole
|
|
||||||
};
|
|
||||||
@@ -187,7 +187,6 @@ app.use('/api/probe', require('./routes/probe'));
|
|||||||
app.use('/api/subtitle', require('./routes/subtitle'));
|
app.use('/api/subtitle', require('./routes/subtitle'));
|
||||||
app.use('/api/settings', require('./routes/settings'));
|
app.use('/api/settings', require('./routes/settings'));
|
||||||
app.use('/api/history', require('./routes/history'));
|
app.use('/api/history', require('./routes/history'));
|
||||||
app.use('/api/kirabot', require('./routes/kirabot'));
|
|
||||||
|
|
||||||
// Version endpoint
|
// Version endpoint
|
||||||
app.get('/api/version', (req, res) => {
|
app.get('/api/version', (req, res) => {
|
||||||
|
|||||||
@@ -1,149 +0,0 @@
|
|||||||
/**
|
|
||||||
* KiraBot proxy — forwards /api/kirabot/* to KiraStream backend
|
|
||||||
* authenticating on behalf of the current kiratv user.
|
|
||||||
*/
|
|
||||||
const express = require('express');
|
|
||||||
const router = express.Router();
|
|
||||||
const { requireAuth } = require('../auth');
|
|
||||||
const { sources } = require('../db');
|
|
||||||
const http = require('http');
|
|
||||||
|
|
||||||
const KIRASTREAM_BASE = process.env.KIRASTREAM_BASE_URL || 'http://10.10.10.234';
|
|
||||||
|
|
||||||
// Token cache: { userId: { token, expires } }
|
|
||||||
const _tokenCache = new Map();
|
|
||||||
|
|
||||||
async function _getKirastreamToken(user) {
|
|
||||||
const cached = _tokenCache.get(user.id);
|
|
||||||
if (cached && cached.expires > Date.now()) {
|
|
||||||
return cached.token;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find user's xtream source pointing to kirastream
|
|
||||||
let db;
|
|
||||||
try {
|
|
||||||
db = await require('../db').loadDb();
|
|
||||||
} catch (e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const src = db.sources.find(
|
|
||||||
(s) => s.ownerId === user.id && s.type === 'xtream' && s.username && s.password
|
|
||||||
);
|
|
||||||
if (!src) return null;
|
|
||||||
|
|
||||||
// POST form to /api/user/auth/token
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const body = `username=${encodeURIComponent(src.username)}&password=${encodeURIComponent(src.password)}`;
|
|
||||||
const url = new URL(`${KIRASTREAM_BASE}/api/user/auth/token`);
|
|
||||||
const options = {
|
|
||||||
hostname: url.hostname,
|
|
||||||
port: url.port || 80,
|
|
||||||
path: url.pathname,
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
|
||||||
'Content-Length': Buffer.byteLength(body),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const req = http.request(options, (res) => {
|
|
||||||
let data = '';
|
|
||||||
res.on('data', (chunk) => { data += chunk; });
|
|
||||||
res.on('end', () => {
|
|
||||||
try {
|
|
||||||
const json = JSON.parse(data);
|
|
||||||
if (json.access_token) {
|
|
||||||
_tokenCache.set(user.id, {
|
|
||||||
token: json.access_token,
|
|
||||||
expires: Date.now() + 25 * 60 * 1000, // 25 min
|
|
||||||
});
|
|
||||||
resolve(json.access_token);
|
|
||||||
} else {
|
|
||||||
resolve(null);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
resolve(null);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
req.on('error', () => resolve(null));
|
|
||||||
req.write(body);
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function _proxyToKirastream(req, res, path, method, body) {
|
|
||||||
const token = await _getKirastreamToken(req.user);
|
|
||||||
if (!token) {
|
|
||||||
return res.status(401).json({ error: 'No se pudo autenticar con KiraStream' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = new URL(`${KIRASTREAM_BASE}/api/user/kirabot${path}`);
|
|
||||||
const bodyStr = body ? JSON.stringify(body) : '';
|
|
||||||
const options = {
|
|
||||||
hostname: url.hostname,
|
|
||||||
port: url.port || 80,
|
|
||||||
path: url.pathname + (url.search || ''),
|
|
||||||
method: method,
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Content-Length': Buffer.byteLength(bodyStr),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const proxyReq = http.request(options, (proxyRes) => {
|
|
||||||
let data = '';
|
|
||||||
proxyRes.on('data', (chunk) => { data += chunk; });
|
|
||||||
proxyRes.on('end', () => {
|
|
||||||
try {
|
|
||||||
const json = JSON.parse(data);
|
|
||||||
res.status(proxyRes.statusCode).json(json);
|
|
||||||
} catch (e) {
|
|
||||||
res.status(proxyRes.statusCode).send(data);
|
|
||||||
}
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
proxyReq.on('error', (err) => {
|
|
||||||
res.status(502).json({ error: 'Error de conexión con KiraStream', detail: err.message });
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
if (bodyStr) proxyReq.write(bodyStr);
|
|
||||||
proxyReq.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
router.use(requireAuth);
|
|
||||||
|
|
||||||
router.post('/chat', async (req, res) => {
|
|
||||||
await _proxyToKirastream(req, res, '/chat', 'POST', req.body);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/chat/history', async (req, res) => {
|
|
||||||
await _proxyToKirastream(req, res, '/chat/history', 'GET', null);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.delete('/chat/history', async (req, res) => {
|
|
||||||
await _proxyToKirastream(req, res, '/chat/history', 'DELETE', null);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/status', async (req, res) => {
|
|
||||||
await _proxyToKirastream(req, res, '/status', 'GET', null);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/recordings', async (req, res) => {
|
|
||||||
const qs = req.query.limit ? `?limit=${req.query.limit}` : '';
|
|
||||||
await _proxyToKirastream(req, res, `/recordings${qs}`, 'GET', null);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post('/recordings', async (req, res) => {
|
|
||||||
await _proxyToKirastream(req, res, '/recordings', 'POST', req.body);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.delete('/recordings/:id', async (req, res) => {
|
|
||||||
await _proxyToKirastream(req, res, `/recordings/${req.params.id}`, 'DELETE', null);
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = router;
|
|
||||||
@@ -15,10 +15,12 @@ const { Readable } = require('stream');
|
|||||||
const auth = require('../auth');
|
const auth = require('../auth');
|
||||||
const { canUserAccessSource } = require('../sourceAccess');
|
const { canUserAccessSource } = require('../sourceAccess');
|
||||||
|
|
||||||
// Las rutas de proxy (categorías, streams, EPG, imágenes, HLS, etc.) son accedidas
|
// Rutas que el navegador pide sin Bearer (HLS, <img>, etc.) deben quedar fuera de JWT
|
||||||
// directamente por el reproductor y los navegadores sin header Authorization.
|
router.use((req, res, next) => {
|
||||||
// El control de acceso a fuentes se hace en /api/sources (CRUD).
|
const p = req.path || (req.url || '').split('?')[0];
|
||||||
// Por eso NO aplicamos requireAuth globalmente aquí.
|
if (p === '/stream' || p === '/image') return next();
|
||||||
|
return auth.requireAuth(req, res, next);
|
||||||
|
});
|
||||||
|
|
||||||
router.param('sourceId', async (req, res, next, id) => {
|
router.param('sourceId', async (req, res, next, id) => {
|
||||||
try {
|
try {
|
||||||
@@ -31,12 +33,8 @@ router.param('sourceId', async (req, res, next, id) => {
|
|||||||
return res.status(404).json({ error: 'Source not found' });
|
return res.status(404).json({ error: 'Source not found' });
|
||||||
}
|
}
|
||||||
if (!canUserAccessSource(req.user, source)) {
|
if (!canUserAccessSource(req.user, source)) {
|
||||||
// Solo bloqueamos si hay usuario autenticado y no tiene acceso
|
|
||||||
// (sin token, req.user es undefined → dejamos pasar, el reproductor no lleva Bearer)
|
|
||||||
if (req.user) {
|
|
||||||
return res.status(403).json({ error: 'No access to this source' });
|
return res.status(403).json({ error: 'No access to this source' });
|
||||||
}
|
}
|
||||||
}
|
|
||||||
req.grantedSource = source;
|
req.grantedSource = source;
|
||||||
next();
|
next();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ const { getDb } = require('../db/sqlite');
|
|||||||
const xtreamApi = require('../services/xtreamApi');
|
const xtreamApi = require('../services/xtreamApi');
|
||||||
const syncService = require('../services/syncService');
|
const syncService = require('../services/syncService');
|
||||||
const m3uParser = require('../services/m3uParser');
|
const m3uParser = require('../services/m3uParser');
|
||||||
const { requireAuth, requireAdmin, isAdminRole } = require('../auth');
|
const { requireAuth, requireAdmin } = require('../auth');
|
||||||
const { canUserAccessSource, getAccessibleSourceIds } = require('../sourceAccess');
|
const { canUserAccessSource, getAccessibleSourceIds } = require('../sourceAccess');
|
||||||
|
|
||||||
router.use(requireAuth);
|
router.use(requireAuth);
|
||||||
@@ -18,9 +18,8 @@ function sanitizeSourceList(list) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseOwnerId(body) {
|
function parseOwnerId(body) {
|
||||||
const raw = body.ownerId;
|
if (body.ownerId === undefined || body.ownerId === '') return null;
|
||||||
if (raw === undefined || raw === null || raw === '') return null;
|
const n = parseInt(body.ownerId, 10);
|
||||||
const n = parseInt(raw, 10);
|
|
||||||
return Number.isNaN(n) ? null : n;
|
return Number.isNaN(n) ? null : n;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,11 +161,10 @@ router.post('/', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let ownerId = null;
|
let ownerId = null;
|
||||||
if (isAdminRole(req.user)) {
|
if (req.user.role === 'admin') {
|
||||||
ownerId = parseOwnerId(req.body);
|
ownerId = parseOwnerId(req.body);
|
||||||
} else {
|
} else {
|
||||||
const uid = req.user.id != null ? Number(req.user.id) : NaN;
|
ownerId = req.user.id;
|
||||||
ownerId = Number.isNaN(uid) ? null : uid;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const source = await sources.create({ type, name, url, username, password, ownerId });
|
const source = await sources.create({ type, name, url, username, password, ownerId });
|
||||||
@@ -196,7 +194,7 @@ router.put('/:id', async (req, res) => {
|
|||||||
username: username !== undefined ? username : existing.username,
|
username: username !== undefined ? username : existing.username,
|
||||||
password: password !== undefined ? password : existing.password
|
password: password !== undefined ? password : existing.password
|
||||||
};
|
};
|
||||||
if (isAdminRole(req.user) && req.body.ownerId !== undefined) {
|
if (req.user.role === 'admin' && req.body.ownerId !== undefined) {
|
||||||
updates.ownerId = parseOwnerId(req.body);
|
updates.ownerId = parseOwnerId(req.body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+61
-11
@@ -5,23 +5,30 @@ const path = require('path');
|
|||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
const db = require('../db');
|
const db = require('../db');
|
||||||
const transcodeSession = require('../services/transcodeSession');
|
const transcodeSession = require('../services/transcodeSession');
|
||||||
|
const streamInput = require('../utils/streamInput');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Probe a stream URL with FFprobe to get its total duration in seconds.
|
* Probe a stream URL with FFprobe to get its total duration in seconds.
|
||||||
* Times out quickly (8 s) so it doesn't block session creation for long.
|
* VOD pesado usa probes mayores y timeout más alto (MKV Jellyfin/remux).
|
||||||
*/
|
*/
|
||||||
function probeDuration(url, ffprobePath, userAgent) {
|
function probeDuration(url, ffprobePath, userAgent) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
|
const sizing = streamInput.probeSizingForUrl(url);
|
||||||
|
const referer = streamInput.deriveRefererFromUrl(url);
|
||||||
const args = [
|
const args = [
|
||||||
'-v', 'quiet',
|
'-v', 'quiet',
|
||||||
'-print_format', 'json',
|
'-print_format', 'json',
|
||||||
'-show_entries', 'format=duration',
|
'-show_entries', 'format=duration',
|
||||||
'-user_agent', userAgent || 'Mozilla/5.0',
|
'-user_agent', userAgent || 'Mozilla/5.0',
|
||||||
'-probesize', '3000000',
|
'-probesize', sizing.probesize,
|
||||||
'-analyzeduration', '2000000',
|
'-analyzeduration', sizing.analyzeduration,
|
||||||
url
|
|
||||||
];
|
];
|
||||||
const proc = execFile(ffprobePath || 'ffprobe', args, { timeout: 8000 }, (err, stdout) => {
|
if (referer) {
|
||||||
|
args.push('-referer', referer);
|
||||||
|
}
|
||||||
|
args.push(url);
|
||||||
|
const timeoutMs = sizing.heavy ? 45000 : 8000;
|
||||||
|
const proc = execFile(ffprobePath || 'ffprobe', args, { timeout: timeoutMs }, (err, stdout) => {
|
||||||
if (err) { resolve(0); return; }
|
if (err) { resolve(0); return; }
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(stdout);
|
const data = JSON.parse(stdout);
|
||||||
@@ -92,7 +99,41 @@ router.post('/session', async (req, res) => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// Wait for playlist to be ready (first segments generated)
|
// Wait for playlist to be ready (first segments generated)
|
||||||
const ready = await session.waitForPlaylist(15000);
|
// VOD pesado (4K HEVC/remux) puede tardar >15 s en generar el primer segmento
|
||||||
|
const waitTimeout = streamInput.isHeavyVodUrl(url) ? 60000 : 15000;
|
||||||
|
let ready = await session.waitForPlaylist(waitTimeout);
|
||||||
|
|
||||||
|
// Fallback a software si el encoder HW falla inmediatamente (error rápido)
|
||||||
|
if (!ready && session.status === 'error' && (settings.hwEncoder || 'software') !== 'software') {
|
||||||
|
console.warn(`[Transcode] HW encoder (${settings.hwEncoder}) falló, reintentando con software libx264...`);
|
||||||
|
await transcodeSession.removeSession(session.id);
|
||||||
|
const swSession = await transcodeSession.createSession(url, {
|
||||||
|
ffmpegPath,
|
||||||
|
userAgent,
|
||||||
|
seekOffset: seekOffset || 0,
|
||||||
|
hwEncoder: 'software',
|
||||||
|
maxResolution: settings.maxResolution || '1080p',
|
||||||
|
quality: settings.quality || 'medium',
|
||||||
|
audioMixPreset: settings.audioMixPreset || 'auto',
|
||||||
|
upscaleEnabled: false,
|
||||||
|
videoMode: videoMode,
|
||||||
|
videoCodec: videoCodec,
|
||||||
|
audioCodec: audioCodec,
|
||||||
|
audioChannels: audioChannels
|
||||||
|
});
|
||||||
|
await swSession.start();
|
||||||
|
ready = await swSession.waitForPlaylist(waitTimeout);
|
||||||
|
if (ready) {
|
||||||
|
console.log(`[Transcode] Software fallback OK → session ${swSession.id}`);
|
||||||
|
return res.json({
|
||||||
|
sessionId: swSession.id,
|
||||||
|
playlistUrl: `/api/transcode/${swSession.id}/stream.m3u8`,
|
||||||
|
status: swSession.status,
|
||||||
|
durationSec: durationSec || 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await transcodeSession.removeSession(swSession.id);
|
||||||
|
}
|
||||||
|
|
||||||
if (!ready) {
|
if (!ready) {
|
||||||
await transcodeSession.removeSession(session.id);
|
await transcodeSession.removeSession(session.id);
|
||||||
@@ -233,6 +274,9 @@ router.get('/', async (req, res) => {
|
|||||||
console.log(`[Transcode] Using User-Agent: ${settings.userAgentPreset}`);
|
console.log(`[Transcode] Using User-Agent: ${settings.userAgentPreset}`);
|
||||||
console.log(`[Transcode] Using binary: ${ffmpegPath}`);
|
console.log(`[Transcode] Using binary: ${ffmpegPath}`);
|
||||||
|
|
||||||
|
const sizing = streamInput.probeSizingForUrl(url);
|
||||||
|
const referer = streamInput.deriveRefererFromUrl(url);
|
||||||
|
|
||||||
// FFmpeg arguments for transcoding
|
// FFmpeg arguments for transcoding
|
||||||
// Optimized for VOD content with incompatible audio (Dolby/AC3/EAC3)
|
// Optimized for VOD content with incompatible audio (Dolby/AC3/EAC3)
|
||||||
// Also works for live streams with ad stitching (Pluto TV, etc.)
|
// Also works for live streams with ad stitching (Pluto TV, etc.)
|
||||||
@@ -240,9 +284,13 @@ router.get('/', async (req, res) => {
|
|||||||
'-hide_banner',
|
'-hide_banner',
|
||||||
'-loglevel', 'warning',
|
'-loglevel', 'warning',
|
||||||
'-user_agent', userAgent,
|
'-user_agent', userAgent,
|
||||||
// Faster startup - reduced probe/analyze for quicker first bytes
|
];
|
||||||
'-probesize', '2000000', // 2MB (reduced from 5MB)
|
if (referer) {
|
||||||
'-analyzeduration', '3000000', // 3 seconds (reduced from 10s)
|
args.push('-referer', referer);
|
||||||
|
}
|
||||||
|
args.push(
|
||||||
|
'-probesize', sizing.probesize,
|
||||||
|
'-analyzeduration', sizing.analyzeduration,
|
||||||
// Error resilience: generate timestamps, discard corrupt packets
|
// Error resilience: generate timestamps, discard corrupt packets
|
||||||
'-fflags', '+genpts+discardcorrupt+nobuffer',
|
'-fflags', '+genpts+discardcorrupt+nobuffer',
|
||||||
// Ignore errors in stream and continue
|
// Ignore errors in stream and continue
|
||||||
@@ -255,7 +303,9 @@ router.get('/', async (req, res) => {
|
|||||||
'-reconnect_delay_max', '3',
|
'-reconnect_delay_max', '3',
|
||||||
// Prevent Range/HEAD requests that some providers reject with 405
|
// Prevent Range/HEAD requests that some providers reject with 405
|
||||||
'-seekable', '0',
|
'-seekable', '0',
|
||||||
'-i', url,
|
'-i', url
|
||||||
|
);
|
||||||
|
args.push(
|
||||||
// Map only first video and audio stream (avoid subtitle streams causing issues)
|
// Map only first video and audio stream (avoid subtitle streams causing issues)
|
||||||
'-map', '0:v:0',
|
'-map', '0:v:0',
|
||||||
'-map', '0:a:0?', // ? makes audio optional if not present
|
'-map', '0:a:0?', // ? makes audio optional if not present
|
||||||
@@ -276,7 +326,7 @@ router.get('/', async (req, res) => {
|
|||||||
'-movflags', 'frag_keyframe+empty_moov+default_base_moof+faststart',
|
'-movflags', 'frag_keyframe+empty_moov+default_base_moof+faststart',
|
||||||
'-flush_packets', '1', // Send data immediately
|
'-flush_packets', '1', // Send data immediately
|
||||||
'-' // Output to stdout
|
'-' // Output to stdout
|
||||||
];
|
);
|
||||||
|
|
||||||
console.log(`[Transcode] Full command: ${ffmpegPath} ${args.join(' ')}`);
|
console.log(`[Transcode] Full command: ${ffmpegPath} ${args.join(' ')}`);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,781 @@
|
|||||||
|
/**
|
||||||
|
* Transcode Session Service
|
||||||
|
*
|
||||||
|
* Manages HLS transcoding sessions with segment caching for VOD seeking.
|
||||||
|
* Each session transcodes a source URL to HLS segments on disk.
|
||||||
|
*
|
||||||
|
* Key features:
|
||||||
|
* - Session-based transcoding with unique IDs
|
||||||
|
* - HLS segment output for seeking support
|
||||||
|
* - Segment caching for fast access
|
||||||
|
* - Session persistence for recovery after restart
|
||||||
|
* - Automatic cleanup of stale sessions
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs').promises;
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const EventEmitter = require('events');
|
||||||
|
const hwDetect = require('./hwDetect');
|
||||||
|
|
||||||
|
// Session storage
|
||||||
|
const sessions = new Map();
|
||||||
|
|
||||||
|
// Cache directory for transcoded segments
|
||||||
|
const CACHE_DIR = path.join(process.cwd(), 'transcode-cache');
|
||||||
|
|
||||||
|
// Session settings
|
||||||
|
const SESSION_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes idle timeout
|
||||||
|
const SEGMENT_DURATION = 4; // seconds per HLS segment
|
||||||
|
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // Check every 5 minutes
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a unique session ID
|
||||||
|
*/
|
||||||
|
function generateSessionId() {
|
||||||
|
return crypto.randomBytes(8).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure cache directory exists
|
||||||
|
*/
|
||||||
|
async function ensureCacheDir() {
|
||||||
|
try {
|
||||||
|
await fs.mkdir(CACHE_DIR, { recursive: true });
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code !== 'EEXIST') throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TranscodeSession class
|
||||||
|
* Manages a single transcoding session from source URL to HLS segments
|
||||||
|
*/
|
||||||
|
class TranscodeSession extends EventEmitter {
|
||||||
|
constructor(url, options = {}) {
|
||||||
|
super();
|
||||||
|
this.id = generateSessionId();
|
||||||
|
this.url = url;
|
||||||
|
this.dir = path.join(CACHE_DIR, this.id);
|
||||||
|
this.playlistPath = path.join(this.dir, 'stream.m3u8');
|
||||||
|
this.process = null;
|
||||||
|
this.segments = new Map(); // segment index -> { ready: boolean, path: string }
|
||||||
|
this.status = 'pending'; // pending | starting | running | stopped | error
|
||||||
|
this.error = null;
|
||||||
|
this.startTime = Date.now();
|
||||||
|
this.lastAccess = Date.now();
|
||||||
|
this.options = {
|
||||||
|
ffmpegPath: options.ffmpegPath || 'ffmpeg',
|
||||||
|
userAgent: options.userAgent || 'Mozilla/5.0',
|
||||||
|
seekOffset: options.seekOffset || 0,
|
||||||
|
hwEncoder: options.hwEncoder || 'software',
|
||||||
|
maxResolution: options.maxResolution || '1080p',
|
||||||
|
quality: options.quality || 'medium',
|
||||||
|
// Upscaling options
|
||||||
|
upscaleEnabled: options.upscaleEnabled || false,
|
||||||
|
upscaleMethod: options.upscaleMethod || 'hardware', // 'hardware' or 'software'
|
||||||
|
upscaleTarget: options.upscaleTarget || '1080p',
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the transcoding process
|
||||||
|
*/
|
||||||
|
async start() {
|
||||||
|
if (this.status === 'running') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.status = 'starting';
|
||||||
|
console.log(`[TranscodeSession ${this.id}] Starting session for: ${this.url}`);
|
||||||
|
|
||||||
|
// Create session directory
|
||||||
|
try {
|
||||||
|
await fs.mkdir(this.dir, { recursive: true });
|
||||||
|
} catch (err) {
|
||||||
|
this.status = 'error';
|
||||||
|
this.error = err.message;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build FFmpeg arguments for HLS output
|
||||||
|
const args = this.buildFFmpegArgs();
|
||||||
|
|
||||||
|
console.log(`[TranscodeSession ${this.id}] Command: ${this.options.ffmpegPath} ${args.join(' ')}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.process = spawn(this.options.ffmpegPath, args, {
|
||||||
|
cwd: this.dir,
|
||||||
|
windowsHide: true
|
||||||
|
});
|
||||||
|
|
||||||
|
this.status = 'running';
|
||||||
|
|
||||||
|
// Handle stdout (should be empty for file output)
|
||||||
|
this.process.stdout.on('data', (data) => {
|
||||||
|
console.log(`[TranscodeSession ${this.id}] stdout: ${data}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle stderr (FFmpeg progress/errors)
|
||||||
|
let stderrBuffer = '';
|
||||||
|
this.process.stderr.on('data', (data) => {
|
||||||
|
stderrBuffer += data.toString();
|
||||||
|
// Log periodically to avoid spam
|
||||||
|
const lines = stderrBuffer.split('\n');
|
||||||
|
if (lines.length > 1) {
|
||||||
|
lines.slice(0, -1).forEach(line => {
|
||||||
|
if (line.trim()) {
|
||||||
|
console.log(`[FFmpeg ${this.id}] ${line}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
stderrBuffer = lines[lines.length - 1];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle process exit
|
||||||
|
this.process.on('exit', (code) => {
|
||||||
|
if (code === 0 || code === null) {
|
||||||
|
console.log(`[TranscodeSession ${this.id}] FFmpeg completed successfully`);
|
||||||
|
this.status = 'stopped';
|
||||||
|
} else if (code !== 255) { // 255 is often from SIGKILL
|
||||||
|
console.error(`[TranscodeSession ${this.id}] FFmpeg exited with code ${code}`);
|
||||||
|
this.status = 'error';
|
||||||
|
this.error = `FFmpeg exited with code ${code}`;
|
||||||
|
}
|
||||||
|
this.process = null;
|
||||||
|
this.emit('exit', code);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle spawn errors
|
||||||
|
this.process.on('error', (err) => {
|
||||||
|
console.error(`[TranscodeSession ${this.id}] FFmpeg error:`, err);
|
||||||
|
this.status = 'error';
|
||||||
|
this.error = err.message;
|
||||||
|
this.emit('error', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save session metadata
|
||||||
|
await this.persist();
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
this.status = 'error';
|
||||||
|
this.error = err.message;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build FFmpeg arguments for HLS output with optional GPU encoding
|
||||||
|
*/
|
||||||
|
buildFFmpegArgs() {
|
||||||
|
const segmentPattern = path.join(this.dir, 'seg%04d.m4s');
|
||||||
|
const videoMode = this.options.videoMode || 'encode';
|
||||||
|
|
||||||
|
// Resolve 'auto' encoder to detected hardware, fallback to software
|
||||||
|
let encoder = this.options.hwEncoder || 'software';
|
||||||
|
if (encoder === 'auto') {
|
||||||
|
const hwCaps = hwDetect.getCapabilities();
|
||||||
|
encoder = hwCaps?.recommended || 'software';
|
||||||
|
console.log(`[TranscodeSession ${this.id}] Auto encoder resolved to: ${encoder}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = [
|
||||||
|
'-hide_banner',
|
||||||
|
'-loglevel', 'warning',
|
||||||
|
'-user_agent', this.options.userAgent,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add hardware acceleration input options based on encoder (only if encoding)
|
||||||
|
if (videoMode === 'encode') {
|
||||||
|
this.addHwAccelInputArgs(args, encoder);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input options (common)
|
||||||
|
args.push(
|
||||||
|
'-probesize', '5000000',
|
||||||
|
'-analyzeduration', '5000000',
|
||||||
|
'-fflags', '+genpts+discardcorrupt',
|
||||||
|
'-err_detect', 'ignore_err',
|
||||||
|
'-reconnect', '1',
|
||||||
|
'-reconnect_streamed', '1',
|
||||||
|
'-reconnect_delay_max', '3'
|
||||||
|
);
|
||||||
|
|
||||||
|
args.push('-i', this.url);
|
||||||
|
|
||||||
|
// Add seek offset if specified (as output option to avoid Range requests)
|
||||||
|
if (this.options.seekOffset > 0) {
|
||||||
|
args.push('-ss', String(this.options.seekOffset));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map streams
|
||||||
|
args.push('-map', '0:v:0');
|
||||||
|
args.push('-map', '0:a:0?');
|
||||||
|
|
||||||
|
// Add video encoder and filters based on selected encoder OR copy
|
||||||
|
if (videoMode === 'copy') {
|
||||||
|
args.push('-c:v', 'copy');
|
||||||
|
|
||||||
|
// Critical for MKV/MP4 -> TS copy: Convert bitstream from AVCC/HVCC to Annex B
|
||||||
|
if (this.options.videoCodec === 'hevc' || this.options.videoCodec === 'h265') {
|
||||||
|
args.push('-bsf:v', 'hevc_mp4toannexb');
|
||||||
|
} else if (this.options.videoCodec === 'h264' || this.options.videoCodec === 'avc') {
|
||||||
|
// Keep Annex-B conversion and force parameter sets to be repeated.
|
||||||
|
// This helps players recover on streams where SPS/PPS appear sporadically,
|
||||||
|
// which often manifests as "audio OK, black video".
|
||||||
|
args.push('-bsf:v', 'h264_mp4toannexb,dump_extra');
|
||||||
|
} else {
|
||||||
|
// Fallback (e.g. unknown codec), try strict extraction
|
||||||
|
args.push('-bsf:v', 'dump_extra');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.addVideoEncoderArgs(args, encoder);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audio: Apply mix preset
|
||||||
|
const audioCodec = this.options.audioCodec?.toLowerCase() || 'unknown';
|
||||||
|
const audioChannels = this.options.audioChannels || 0;
|
||||||
|
const audioMixPreset = this.options.audioMixPreset || 'auto';
|
||||||
|
const isStereoAac = audioCodec.includes('aac') && audioChannels === 2;
|
||||||
|
|
||||||
|
// Define pan filter presets for 5.1 -> Stereo downmix
|
||||||
|
const AUDIO_MIX_FILTERS = {
|
||||||
|
// ITU-R BS.775 Standard: Mathematically balanced, transparent
|
||||||
|
itu: 'pan=stereo|FL=FL+0.707*FC+0.707*BL+0.5*LFE|FR=FR+0.707*FC+0.707*BR+0.5*LFE',
|
||||||
|
// Night Mode: Heavy dialogue boost, reduced bass/surrounds for quiet viewing
|
||||||
|
night: 'pan=stereo|FL=0.5*FL+1.2*FC+0.3*BL+0.1*LFE|FR=0.5*FR+1.2*FC+0.3*BR+0.1*LFE',
|
||||||
|
// Cinematic: Wide soundstage, immersive (original "dialogue boost" mix)
|
||||||
|
cinematic: 'pan=stereo|FL=FC+0.80*FL+0.60*BL+0.5*LFE|FR=FC+0.80*FR+0.60*BR+0.5*LFE'
|
||||||
|
};
|
||||||
|
|
||||||
|
if (audioMixPreset === 'passthrough') {
|
||||||
|
// Passthrough: Always copy audio, no processing
|
||||||
|
console.log(`[TranscodeSession ${this.id}] Audio: Passthrough (copy)`);
|
||||||
|
args.push('-c:a', 'copy');
|
||||||
|
} else if (audioMixPreset === 'auto' && isStereoAac) {
|
||||||
|
// Auto + Stereo AAC source: Smart copy
|
||||||
|
console.log(`[TranscodeSession ${this.id}] Audio: Auto (Smart Copy) - Source is Stereo AAC`);
|
||||||
|
args.push('-c:a', 'copy');
|
||||||
|
} else {
|
||||||
|
// Transcode to AAC with selected mix preset (default to ITU for 'auto')
|
||||||
|
const mixPreset = (audioMixPreset === 'auto') ? 'itu' : audioMixPreset;
|
||||||
|
const panFilter = AUDIO_MIX_FILTERS[mixPreset] || AUDIO_MIX_FILTERS.itu;
|
||||||
|
|
||||||
|
console.log(`[TranscodeSession ${this.id}] Audio: ${mixPreset.toUpperCase()} mix (${audioCodec} ${audioChannels}ch -> Stereo AAC)`);
|
||||||
|
args.push(
|
||||||
|
'-c:a', 'aac',
|
||||||
|
'-ar', '48000',
|
||||||
|
'-b:a', '192k',
|
||||||
|
'-af', `${panFilter},aresample=async=1`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// HLS output options
|
||||||
|
args.push(
|
||||||
|
'-f', 'hls',
|
||||||
|
'-hls_time', String(SEGMENT_DURATION),
|
||||||
|
'-hls_list_size', '0', // Keep all segments in playlist
|
||||||
|
'-hls_flags', 'independent_segments+append_list',
|
||||||
|
'-hls_segment_type', 'mpegts',
|
||||||
|
'-hls_segment_filename', path.join(this.dir, 'seg%04d.ts'),
|
||||||
|
this.playlistPath
|
||||||
|
);
|
||||||
|
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add hardware acceleration input arguments
|
||||||
|
*/
|
||||||
|
addHwAccelInputArgs(args, encoder) {
|
||||||
|
switch (encoder) {
|
||||||
|
case 'nvenc':
|
||||||
|
// NVIDIA CUDA/NVDEC hardware decoding
|
||||||
|
args.push(
|
||||||
|
'-hwaccel', 'cuda',
|
||||||
|
'-hwaccel_output_format', 'cuda'
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'vaapi':
|
||||||
|
// VAAPI hardware decoding (Linux)
|
||||||
|
args.push(
|
||||||
|
'-hwaccel', 'vaapi',
|
||||||
|
'-hwaccel_device', '/dev/dri/renderD128',
|
||||||
|
'-hwaccel_output_format', 'vaapi'
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'qsv':
|
||||||
|
// Intel QuickSync hardware decoding
|
||||||
|
args.push(
|
||||||
|
'-hwaccel', 'qsv',
|
||||||
|
'-hwaccel_output_format', 'qsv'
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'amf':
|
||||||
|
// AMD AMF (no hwaccel input, AMF is encode-only)
|
||||||
|
// Decode on CPU, encode on GPU
|
||||||
|
break;
|
||||||
|
case 'software':
|
||||||
|
case 'auto':
|
||||||
|
default:
|
||||||
|
// No hardware acceleration for input
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add video encoder arguments based on selected encoder
|
||||||
|
*/
|
||||||
|
addVideoEncoderArgs(args, encoder) {
|
||||||
|
const resolution = this.getTargetHeight();
|
||||||
|
const quality = this.options.quality || 'medium';
|
||||||
|
|
||||||
|
// Quality presets mapping
|
||||||
|
const qualityPresets = {
|
||||||
|
'high': { nvenc: 18, vaapi: 18, qsv: 18, amf: 18, software: 18 },
|
||||||
|
'medium': { nvenc: 24, vaapi: 24, qsv: 24, amf: 24, software: 23 },
|
||||||
|
'low': { nvenc: 30, vaapi: 30, qsv: 30, amf: 30, software: 28 }
|
||||||
|
};
|
||||||
|
const qp = qualityPresets[quality] || qualityPresets.medium;
|
||||||
|
|
||||||
|
switch (encoder) {
|
||||||
|
case 'nvenc':
|
||||||
|
this.addNvencEncoderArgs(args, resolution, qp.nvenc);
|
||||||
|
break;
|
||||||
|
case 'amf':
|
||||||
|
this.addAmfEncoderArgs(args, resolution, qp.amf);
|
||||||
|
break;
|
||||||
|
case 'vaapi':
|
||||||
|
this.addVaapiEncoderArgs(args, resolution, qp.vaapi);
|
||||||
|
break;
|
||||||
|
case 'qsv':
|
||||||
|
this.addQsvEncoderArgs(args, resolution, qp.qsv);
|
||||||
|
break;
|
||||||
|
case 'software':
|
||||||
|
case 'auto':
|
||||||
|
default:
|
||||||
|
this.addSoftwareEncoderArgs(args, resolution, qp.software);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get target height based on maxResolution or upscaleTarget setting
|
||||||
|
* When upscaling is enabled, uses the upscaleTarget resolution.
|
||||||
|
* Otherwise, uses maxResolution to cap the output.
|
||||||
|
*/
|
||||||
|
getTargetHeight() {
|
||||||
|
const resolutionMap = {
|
||||||
|
'4k': 2160,
|
||||||
|
'1080p': 1080,
|
||||||
|
'720p': 720,
|
||||||
|
'480p': 480
|
||||||
|
};
|
||||||
|
|
||||||
|
// When upscaling is enabled, use the upscale target resolution
|
||||||
|
if (this.options.upscaleEnabled) {
|
||||||
|
const target = resolutionMap[this.options.upscaleTarget] || 1080;
|
||||||
|
console.log(`[TranscodeSession ${this.id}] Upscale target height: ${target}p`);
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, use max resolution as the cap
|
||||||
|
return resolutionMap[this.options.maxResolution] || 1080;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build scale filter string based on encoder and upscaling settings
|
||||||
|
* @param {string} encoder - The encoder being used
|
||||||
|
* @param {number} height - Target height
|
||||||
|
*/
|
||||||
|
buildScaleFilter(encoder, height) {
|
||||||
|
const useUpscale = this.options.upscaleEnabled;
|
||||||
|
const upscaleMethod = this.options.upscaleMethod || 'hardware';
|
||||||
|
|
||||||
|
// Log upscaling status
|
||||||
|
if (useUpscale) {
|
||||||
|
console.log(`[TranscodeSession ${this.id}] Upscaling: ${upscaleMethod} method to ${height}p`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hardware scaling filters (for both upscale and downscale)
|
||||||
|
if (upscaleMethod === 'hardware' || !useUpscale) {
|
||||||
|
switch (encoder) {
|
||||||
|
case 'nvenc':
|
||||||
|
// NVIDIA CUDA scaling with Lanczos
|
||||||
|
// Force nv12 (8-bit) output to handle 10-bit inputs (fixes "10 bit encode not supported")
|
||||||
|
return `scale_cuda=-2:${height}:interp_algo=lanczos:format=nv12`;
|
||||||
|
case 'vaapi':
|
||||||
|
return `scale_vaapi=w=-2:h=${height}:format=nv12`;
|
||||||
|
case 'qsv':
|
||||||
|
return `scale_qsv=w=-2:h=${height}:format=nv12`;
|
||||||
|
case 'amf':
|
||||||
|
// AMF uses CPU decode, so use software scale
|
||||||
|
return useUpscale ? `scale=-2:${height}:flags=lanczos` : `scale=-2:${height}`;
|
||||||
|
case 'software':
|
||||||
|
default:
|
||||||
|
return useUpscale ? `scale=-2:${height}:flags=lanczos` : `scale=-2:${height}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Software Lanczos scaling (high quality, slower)
|
||||||
|
return `scale=-2:${height}:flags=lanczos`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NVIDIA NVENC encoder arguments
|
||||||
|
*/
|
||||||
|
addNvencEncoderArgs(args, height, qp) {
|
||||||
|
// Video filter for scaling on GPU
|
||||||
|
args.push('-vf', this.buildScaleFilter('nvenc', height));
|
||||||
|
|
||||||
|
// NVENC encoder with quality settings
|
||||||
|
// Using portable options that work across FFmpeg builds
|
||||||
|
args.push(
|
||||||
|
'-c:v', 'h264_nvenc',
|
||||||
|
'-preset', 'p4', // Balanced preset (p1=fastest, p7=best)
|
||||||
|
'-rc', 'constqp', // Constant QP mode
|
||||||
|
'-qp', String(qp),
|
||||||
|
'-bf', '3' // B-frames for better compression
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AMD AMF encoder arguments
|
||||||
|
*/
|
||||||
|
addAmfEncoderArgs(args, height, qp) {
|
||||||
|
// CPU decoding + software scale + AMF encode
|
||||||
|
args.push('-vf', this.buildScaleFilter('amf', height));
|
||||||
|
|
||||||
|
args.push(
|
||||||
|
'-c:v', 'h264_amf',
|
||||||
|
'-quality', 'quality', // Quality preset
|
||||||
|
'-rc', 'cqp', // Constant QP
|
||||||
|
'-qp_i', String(qp),
|
||||||
|
'-qp_p', String(qp + 2),
|
||||||
|
'-qp_b', String(qp + 4),
|
||||||
|
'-pix_fmt', 'yuv420p' // Force 8-bit output for compatibility
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* VAAPI encoder arguments (Linux)
|
||||||
|
*/
|
||||||
|
addVaapiEncoderArgs(args, height, qp) {
|
||||||
|
// VAAPI filter chain:
|
||||||
|
// 1. scale_vaapi to resize on GPU
|
||||||
|
// 2. Ensure output format is nv12 for maximum encoder compatibility
|
||||||
|
// The format is handled automatically when using -hwaccel_output_format vaapi
|
||||||
|
args.push('-vf', this.buildScaleFilter('vaapi', height));
|
||||||
|
|
||||||
|
// VAAPI encoder with quality setting
|
||||||
|
// Note: -global_quality is the portable way to set quality for VAAPI
|
||||||
|
args.push(
|
||||||
|
'-c:v', 'h264_vaapi',
|
||||||
|
'-profile:v', 'main', // Use main profile for compatibility
|
||||||
|
'-global_quality', String(qp),
|
||||||
|
'-bf', '3',
|
||||||
|
'-pix_fmt', 'yuv420p' // Force 8-bit output for compatibility
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intel QuickSync encoder arguments
|
||||||
|
*/
|
||||||
|
addQsvEncoderArgs(args, height, qp) {
|
||||||
|
// Scale on QSV
|
||||||
|
args.push('-vf', this.buildScaleFilter('qsv', height));
|
||||||
|
|
||||||
|
args.push(
|
||||||
|
'-c:v', 'h264_qsv',
|
||||||
|
'-preset', 'medium',
|
||||||
|
'-global_quality', String(qp),
|
||||||
|
'-look_ahead', '1',
|
||||||
|
'-look_ahead_depth', '40',
|
||||||
|
'-pix_fmt', 'yuv420p' // Force 8-bit output for compatibility
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Software encoder arguments (fallback)
|
||||||
|
*/
|
||||||
|
addSoftwareEncoderArgs(args, height, crf) {
|
||||||
|
// Software scaling (use Lanczos for upscaling if enabled)
|
||||||
|
args.push('-vf', this.buildScaleFilter('software', height));
|
||||||
|
|
||||||
|
args.push(
|
||||||
|
'-c:v', 'libx264',
|
||||||
|
'-preset', 'veryfast', // Fast for real-time
|
||||||
|
'-crf', String(crf),
|
||||||
|
'-profile:v', 'high',
|
||||||
|
'-level', '4.1',
|
||||||
|
'-pix_fmt', 'yuv420p' // Force 8-bit output for compatibility (fixes 10-bit input errors)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the transcoding process
|
||||||
|
*/
|
||||||
|
stop() {
|
||||||
|
if (this.process) {
|
||||||
|
console.log(`[TranscodeSession ${this.id}] Stopping FFmpeg process`);
|
||||||
|
this.process.kill('SIGTERM');
|
||||||
|
// Force kill after 2 seconds if still running
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.process) {
|
||||||
|
this.process.kill('SIGKILL');
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
this.status = 'stopped';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update last access time (prevents cleanup)
|
||||||
|
*/
|
||||||
|
touch() {
|
||||||
|
this.lastAccess = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if playlist exists and is ready
|
||||||
|
*/
|
||||||
|
async isPlaylistReady() {
|
||||||
|
try {
|
||||||
|
await fs.access(this.playlistPath);
|
||||||
|
const content = await fs.readFile(this.playlistPath, 'utf8');
|
||||||
|
// Check if playlist has at least one segment
|
||||||
|
return content.includes('.ts');
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for playlist to be ready (with timeout)
|
||||||
|
* Fast-fails if FFmpeg exited with an error before the timeout.
|
||||||
|
*/
|
||||||
|
async waitForPlaylist(timeoutMs = 10000) {
|
||||||
|
const startTime = Date.now();
|
||||||
|
while (Date.now() - startTime < timeoutMs) {
|
||||||
|
if (await this.isPlaylistReady()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Fast-fail: FFmpeg already exited with an error — no point waiting
|
||||||
|
if (this.status === 'error' && !this.process) {
|
||||||
|
console.log(`[TranscodeSession ${this.id}] Fast-fail: FFmpeg exited with error, aborting wait`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 200));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the HLS playlist content
|
||||||
|
*/
|
||||||
|
async getPlaylist() {
|
||||||
|
this.touch();
|
||||||
|
try {
|
||||||
|
return await fs.readFile(this.playlistPath, 'utf8');
|
||||||
|
} catch (err) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a specific segment
|
||||||
|
*/
|
||||||
|
async getSegment(segmentName) {
|
||||||
|
this.touch();
|
||||||
|
const segmentPath = path.join(this.dir, segmentName);
|
||||||
|
try {
|
||||||
|
await fs.access(segmentPath);
|
||||||
|
return segmentPath;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save session metadata to disk for recovery
|
||||||
|
*/
|
||||||
|
async persist() {
|
||||||
|
const metadata = {
|
||||||
|
id: this.id,
|
||||||
|
url: this.url,
|
||||||
|
status: this.status,
|
||||||
|
startTime: this.startTime,
|
||||||
|
lastAccess: this.lastAccess,
|
||||||
|
options: this.options,
|
||||||
|
seekOffset: this.options.seekOffset
|
||||||
|
};
|
||||||
|
const metaPath = path.join(this.dir, 'session.json');
|
||||||
|
await fs.writeFile(metaPath, JSON.stringify(metadata, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore a session from disk metadata
|
||||||
|
*/
|
||||||
|
static async restore(sessionDir) {
|
||||||
|
const metaPath = path.join(sessionDir, 'session.json');
|
||||||
|
try {
|
||||||
|
const data = await fs.readFile(metaPath, 'utf8');
|
||||||
|
const metadata = JSON.parse(data);
|
||||||
|
const session = new TranscodeSession(metadata.url, metadata.options);
|
||||||
|
session.id = metadata.id;
|
||||||
|
session.dir = sessionDir;
|
||||||
|
session.playlistPath = path.join(sessionDir, 'stream.m3u8');
|
||||||
|
session.startTime = metadata.startTime;
|
||||||
|
session.lastAccess = metadata.lastAccess;
|
||||||
|
session.status = 'stopped'; // Not running after restart
|
||||||
|
return session;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to restore session from ${sessionDir}:`, err.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete session directory and all segments
|
||||||
|
*/
|
||||||
|
async cleanup() {
|
||||||
|
this.stop();
|
||||||
|
try {
|
||||||
|
await fs.rm(this.dir, { recursive: true, force: true });
|
||||||
|
console.log(`[TranscodeSession ${this.id}] Cleaned up session directory`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[TranscodeSession ${this.id}] Failed to cleanup:`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session Manager
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new transcode session
|
||||||
|
*/
|
||||||
|
async function createSession(url, options = {}) {
|
||||||
|
await ensureCacheDir();
|
||||||
|
const session = new TranscodeSession(url, options);
|
||||||
|
sessions.set(session.id, session);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an existing session by ID
|
||||||
|
*/
|
||||||
|
function getSession(sessionId) {
|
||||||
|
const session = sessions.get(sessionId);
|
||||||
|
if (session) {
|
||||||
|
session.touch();
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create a session for a URL (reuses existing if still valid)
|
||||||
|
*/
|
||||||
|
async function getOrCreateSession(url, options = {}) {
|
||||||
|
// Check for existing session with same URL
|
||||||
|
for (const session of sessions.values()) {
|
||||||
|
if (session.url === url && session.status === 'running') {
|
||||||
|
session.touch();
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Create new session
|
||||||
|
return createSession(url, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop and remove a session
|
||||||
|
*/
|
||||||
|
async function removeSession(sessionId) {
|
||||||
|
const session = sessions.get(sessionId);
|
||||||
|
if (session) {
|
||||||
|
await session.cleanup();
|
||||||
|
sessions.delete(sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup stale sessions (idle for too long)
|
||||||
|
*/
|
||||||
|
async function cleanupStaleSessions() {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [id, session] of sessions) {
|
||||||
|
if (now - session.lastAccess > SESSION_TIMEOUT_MS) {
|
||||||
|
console.log(`[TranscodeSession] Cleaning up stale session ${id}`);
|
||||||
|
await removeSession(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recover sessions from disk after server restart
|
||||||
|
*/
|
||||||
|
async function recoverSessions() {
|
||||||
|
try {
|
||||||
|
await fs.access(CACHE_DIR);
|
||||||
|
const dirs = await fs.readdir(CACHE_DIR, { withFileTypes: true });
|
||||||
|
|
||||||
|
for (const dirent of dirs) {
|
||||||
|
if (dirent.isDirectory()) {
|
||||||
|
const sessionDir = path.join(CACHE_DIR, dirent.name);
|
||||||
|
const session = await TranscodeSession.restore(sessionDir);
|
||||||
|
if (session) {
|
||||||
|
sessions.set(session.id, session);
|
||||||
|
console.log(`[TranscodeSession] Recovered session ${session.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Cache dir doesn't exist yet, that's fine
|
||||||
|
if (err.code !== 'ENOENT') {
|
||||||
|
console.error('[TranscodeSession] Error recovering sessions:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start cleanup interval
|
||||||
|
*/
|
||||||
|
let cleanupInterval = null;
|
||||||
|
function startCleanupInterval() {
|
||||||
|
if (!cleanupInterval) {
|
||||||
|
cleanupInterval = setInterval(cleanupStaleSessions, CLEANUP_INTERVAL_MS);
|
||||||
|
cleanupInterval.unref(); // Don't prevent process exit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all active sessions (for debugging/monitoring)
|
||||||
|
*/
|
||||||
|
function getAllSessions() {
|
||||||
|
return Array.from(sessions.values()).map(s => ({
|
||||||
|
id: s.id,
|
||||||
|
url: s.url,
|
||||||
|
status: s.status,
|
||||||
|
startTime: s.startTime,
|
||||||
|
lastAccess: s.lastAccess,
|
||||||
|
idleMs: Date.now() - s.lastAccess
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
TranscodeSession,
|
||||||
|
createSession,
|
||||||
|
getSession,
|
||||||
|
getOrCreateSession,
|
||||||
|
removeSession,
|
||||||
|
cleanupStaleSessions,
|
||||||
|
recoverSessions,
|
||||||
|
startCleanupInterval,
|
||||||
|
getAllSessions,
|
||||||
|
CACHE_DIR,
|
||||||
|
SEGMENT_DURATION
|
||||||
|
};
|
||||||
@@ -18,6 +18,7 @@ const fs = require('fs').promises;
|
|||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const EventEmitter = require('events');
|
const EventEmitter = require('events');
|
||||||
const hwDetect = require('./hwDetect');
|
const hwDetect = require('./hwDetect');
|
||||||
|
const streamInput = require('../utils/streamInput');
|
||||||
|
|
||||||
// Session storage
|
// Session storage
|
||||||
const sessions = new Map();
|
const sessions = new Map();
|
||||||
@@ -181,11 +182,17 @@ class TranscodeSession extends EventEmitter {
|
|||||||
console.log(`[TranscodeSession ${this.id}] Auto encoder resolved to: ${encoder}`);
|
console.log(`[TranscodeSession ${this.id}] Auto encoder resolved to: ${encoder}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sizing = streamInput.probeSizingForUrl(this.url);
|
||||||
|
const referer = streamInput.deriveRefererFromUrl(this.url);
|
||||||
|
|
||||||
const args = [
|
const args = [
|
||||||
'-hide_banner',
|
'-hide_banner',
|
||||||
'-loglevel', 'warning',
|
'-loglevel', 'warning',
|
||||||
'-user_agent', this.options.userAgent,
|
'-user_agent', this.options.userAgent,
|
||||||
];
|
];
|
||||||
|
if (referer) {
|
||||||
|
args.push('-referer', referer);
|
||||||
|
}
|
||||||
|
|
||||||
// Add hardware acceleration input options based on encoder (only if encoding)
|
// Add hardware acceleration input options based on encoder (only if encoding)
|
||||||
if (videoMode === 'encode') {
|
if (videoMode === 'encode') {
|
||||||
@@ -194,8 +201,8 @@ class TranscodeSession extends EventEmitter {
|
|||||||
|
|
||||||
// Input options (common)
|
// Input options (common)
|
||||||
args.push(
|
args.push(
|
||||||
'-probesize', '5000000',
|
'-probesize', sizing.probesize,
|
||||||
'-analyzeduration', '5000000',
|
'-analyzeduration', sizing.analyzeduration,
|
||||||
'-fflags', '+genpts+discardcorrupt',
|
'-fflags', '+genpts+discardcorrupt',
|
||||||
'-err_detect', 'ignore_err',
|
'-err_detect', 'ignore_err',
|
||||||
'-reconnect', '1',
|
'-reconnect', '1',
|
||||||
@@ -299,12 +306,12 @@ class TranscodeSession extends EventEmitter {
|
|||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case 'vaapi':
|
case 'vaapi':
|
||||||
// VAAPI hardware decoding (Linux)
|
// Solo registramos el dispositivo VAAPI globalmente.
|
||||||
args.push(
|
// Se usa decodificación SOFTWARE para máxima compatibilidad con
|
||||||
'-hwaccel', 'vaapi',
|
// cualquier codec de entrada (HEVC, VP9, etc.) — el GPU Intel
|
||||||
'-hwaccel_device', '/dev/dri/renderD128',
|
// puede no tener soporte de decodificación para todos ellos.
|
||||||
'-hwaccel_output_format', 'vaapi'
|
// El upload a memoria VAAPI se hace en el filtro de vídeo.
|
||||||
);
|
args.push('-vaapi_device', '/dev/dri/renderD128');
|
||||||
break;
|
break;
|
||||||
case 'qsv':
|
case 'qsv':
|
||||||
// Intel QuickSync hardware decoding
|
// Intel QuickSync hardware decoding
|
||||||
@@ -407,7 +414,9 @@ class TranscodeSession extends EventEmitter {
|
|||||||
// Force nv12 (8-bit) output to handle 10-bit inputs (fixes "10 bit encode not supported")
|
// Force nv12 (8-bit) output to handle 10-bit inputs (fixes "10 bit encode not supported")
|
||||||
return `scale_cuda=-2:${height}:interp_algo=lanczos:format=nv12`;
|
return `scale_cuda=-2:${height}:interp_algo=lanczos:format=nv12`;
|
||||||
case 'vaapi':
|
case 'vaapi':
|
||||||
return `scale_vaapi=w=-2:h=${height}:format=nv12`;
|
// Software decode → scale → nv12 → hwupload → VAAPI encode
|
||||||
|
// format=nv12 ANTES de hwupload es obligatorio para Intel iGPU en LXC
|
||||||
|
return `scale=-2:${height},format=nv12,hwupload`;
|
||||||
case 'qsv':
|
case 'qsv':
|
||||||
return `scale_qsv=w=-2:h=${height}:format=nv12`;
|
return `scale_qsv=w=-2:h=${height}:format=nv12`;
|
||||||
case 'amf':
|
case 'amf':
|
||||||
@@ -469,14 +478,11 @@ class TranscodeSession extends EventEmitter {
|
|||||||
// The format is handled automatically when using -hwaccel_output_format vaapi
|
// The format is handled automatically when using -hwaccel_output_format vaapi
|
||||||
args.push('-vf', this.buildScaleFilter('vaapi', height));
|
args.push('-vf', this.buildScaleFilter('vaapi', height));
|
||||||
|
|
||||||
// VAAPI encoder with quality setting
|
// VAAPI encoder. No especificar -profile:v ni -pix_fmt (los gestiona el driver).
|
||||||
// Note: -global_quality is the portable way to set quality for VAAPI
|
// -bf 3 puede causar problemas en algunos drivers Intel; se omite.
|
||||||
args.push(
|
args.push(
|
||||||
'-c:v', 'h264_vaapi',
|
'-c:v', 'h264_vaapi',
|
||||||
'-profile:v', 'main', // Use main profile for compatibility
|
'-global_quality', String(qp)
|
||||||
'-global_quality', String(qp),
|
|
||||||
'-bf', '3',
|
|
||||||
'-pix_fmt', 'yuv420p' // Force 8-bit output for compatibility
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
const { isAdminRole } = require('./auth');
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Who may use a source for playback and API access.
|
* Who may use a source for playback and API access.
|
||||||
* ownerId null/undefined: legacy shared — any authenticated user may use it.
|
* ownerId null/undefined: legacy shared — any authenticated user may use it.
|
||||||
@@ -7,12 +5,10 @@ const { isAdminRole } = require('./auth');
|
|||||||
*/
|
*/
|
||||||
function canUserAccessSource(user, source) {
|
function canUserAccessSource(user, source) {
|
||||||
if (!user || !source) return false;
|
if (!user || !source) return false;
|
||||||
if (isAdminRole(user)) return true;
|
if (user.role === 'admin') return true;
|
||||||
const oid = source.ownerId;
|
const oid = source.ownerId;
|
||||||
if (oid == null || oid === undefined) return true;
|
if (oid == null || oid === undefined) return true;
|
||||||
const uid = user.id != null ? Number(user.id) : NaN;
|
return Number(oid) === Number(user.id);
|
||||||
const oidN = Number(oid);
|
|
||||||
return !Number.isNaN(uid) && !Number.isNaN(oidN) && oidN === uid;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getAccessibleSourceIds(req, sourcesApi) {
|
async function getAccessibleSourceIds(req, sourcesApi) {
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opciones de entrada HTTP para ffprobe/ffmpeg con orígenes tipo Xtream / Jellyfin (Dispatcharr).
|
||||||
|
* Muchos backends exigen Referer/origin coherentes además del User-Agent.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function deriveRefererFromUrl(inputUrl) {
|
||||||
|
try {
|
||||||
|
const u = new URL(inputUrl);
|
||||||
|
return `${u.origin}/`;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** VOD pesado (Remux Jellyfin MKV/MP4, GOP largo) — probes más grandes. */
|
||||||
|
function isHeavyVodUrl(url) {
|
||||||
|
if (!url || typeof url !== 'string') return false;
|
||||||
|
const u = url.toLowerCase();
|
||||||
|
if (u.includes('/movie/') || u.includes('/series/')) return true;
|
||||||
|
if (/\.mkv\b/i.test(url) || /\.mp4\b/i.test(url)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function probeSizingForUrl(url) {
|
||||||
|
if (isHeavyVodUrl(url)) {
|
||||||
|
/** Microseconds; VOD Jellyfin/remux pueden necesitar leer bastante antes del analyze */
|
||||||
|
return { probesize: '32000000', analyzeduration: '45000000', heavy: true };
|
||||||
|
}
|
||||||
|
return { probesize: '8000000', analyzeduration: '8000000', heavy: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
deriveRefererFromUrl,
|
||||||
|
isHeavyVodUrl,
|
||||||
|
probeSizingForUrl
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user