harii88 commited on
Commit
c13aa90
·
verified ·
1 Parent(s): 39b423a

Update frontend/index.html

Browse files
Files changed (1) hide show
  1. frontend/index.html +364 -82
frontend/index.html CHANGED
@@ -5,36 +5,47 @@
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
  <title>TV Archive Manager</title>
7
  <script src="https://cdn.tailwindcss.com"></script>
8
- <script>tailwind.config={theme:{extend:{colors:{dark:{900:'#0b1120',800:'#111827',700:'#1f2937',600:'#374151'}}}}}</script>
 
 
 
 
 
 
 
 
 
 
9
  <style>
10
- body{background:#0b1120;color:#e2e8f0;font-family:system-ui,sans-serif}
11
- .glass{background:rgba(17,24,39,0.8);backdrop-filter:blur(12px);border:1px solid rgba(55,65,81,0.5)}
12
- .tab-active{border-bottom:2px solid #3b82f6;color:#60a5fa;font-weight:600}
13
- table{border-collapse:collapse;width:100%}
14
- th,td{padding:12px;text-align:left;border-bottom:1px solid #374151}
15
- th{background:#111827;font-weight:500;color:#9ca3af}
16
- tr:hover{background:#1f2937}
17
- .badge{padding:4px 8px;border-radius:999px;font-size:0.7rem;font-weight:500;text-transform:uppercase}
18
- .status-scheduled{background:#1d4ed8;color:#bfdbfe}
19
- .status-recording{background:#b45309;color:#fef3c7;animation:pulse 2s infinite}
20
- .status-completed{background:#047857;color:#d1fae5}
21
- @keyframes pulse{0%,100%{opacity:1}50%{opacity:0.7}}
22
- .btn{padding:8px 16px;border-radius:6px;cursor:pointer;transition:all 0.2s;font-weight:500}
23
- .btn-primary{background:#2563eb}.btn-primary:hover{background:#1d4ed8}
24
- .btn-danger{background:#dc2626}.btn-danger:hover{background:#b91c1c}
25
- .btn-secondary{background:#4b5563}.btn-secondary:hover{background:#374151}
26
- input,select{width:100%;padding:10px;background:#111827;border:1px solid #374151;border-radius:6px;color:#e2e8f0;outline:none}
27
- input:focus,select:focus{border-color:#3b82f6}
28
- .modal{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.9);align-items:center;justify-content:center;z-index:50}
29
- .modal.active{display:flex}
30
- .modal-content{background:#111827;padding:24px;border-radius:12px;width:400px;max-width:95%;border:1px solid #374151}
31
- .modal-content-lg{width:520px}
32
- .modal-content-xl{width:640px;max-width:95%}
33
- .scrollbar::-webkit-scrollbar{width:6px}.scrollbar::-webkit-scrollbar-thumb{background:#374151;border-radius:3px}
34
- video{width:100%;border-radius:8px;background:#000;outline:none}
35
- video::-webkit-media-controls-panel{background:rgba(17,24,39,0.9)}
36
- #loginPage{position:fixed;top:0;left:0;width:100%;height:100%;background:#0b1120;display:flex;align-items:center;justify-content:center;z-index:100}
37
- #mainApp{display:none}
 
38
  </style>
39
  </head>
40
  <body>
@@ -42,81 +53,352 @@ video::-webkit-media-controls-panel{background:rgba(17,24,39,0.9)}
42
  <div class="modal-content">
43
  <h2 class="text-xl font-bold mb-6 text-center text-white">TV Archive Manager</h2>
44
  <form id="loginForm" class="space-y-4">
45
- <div><label class="text-xs text-gray-400">Username</label><input id="loginUser" type="text" placeholder="admin" required autocomplete="username"></div>
46
- <div><label class="text-xs text-gray-400">Password</label><input id="loginPass" type="password" placeholder="••••••••" required autocomplete="current-password"></div>
 
 
 
 
 
 
47
  <p id="loginError" class="text-red-400 text-sm hidden"></p>
48
  <button type="submit" class="btn btn-primary w-full">Sign In</button>
49
  </form>
50
  </div>
51
  </div>
 
52
  <div id="mainApp" class="p-4 md:p-8 min-h-screen">
53
- <header class="flex flex-col md:flex-row justify-between items-center mb-8 gap-4">
54
- <h1 class="text-2xl font-bold tracking-tight text-white">TV Archive Manager</h1>
55
- <div class="flex items-center gap-3">
56
- <span id="userInfo" class="text-sm text-gray-400"></span>
57
- <button class="btn btn-secondary text-sm" onclick="doLogout()">Logout</button>
58
- <button class="btn btn-primary" onclick="openModal('recordModal')">+ Schedule Record</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  </div>
60
- </header>
61
- <div class="glass rounded-xl p-1 md:p-4 mb-6 shadow-xl">
62
- <nav class="flex border-b border-gray-700 mb-4 px-2">
63
- <button class="tab-active px-4 py-3" data-tab="sources">Sources</button>
64
- <button class="px-4 py-3 text-gray-400 hover:text-blue-400" data-tab="epg">EPG Guide</button>
65
- <button class="px-4 py-3 text-gray-400 hover:text-blue-400" data-tab="recordings">Recordings</button>
66
- </nav>
67
- <div id="tab-sources" class="tab-content p-2"><div id="sourceTableContainer" class="overflow-x-auto scrollbar"></div></div>
68
- <div id="tab-epg" class="tab-content hidden p-2">
69
- <div class="mb-4 flex flex-col sm:flex-row gap-2"><select id="epgSourceSelect" class="sm:w-64"></select><button class="btn btn-primary" onclick="loadEPG()">Refresh EPG</button></div>
70
- <div id="epgTableContainer" class="overflow-x-auto scrollbar h-96"></div>
71
- </div>
72
- <div id="tab-recordings" class="tab-content hidden p-2"><div id="recordingTableContainer" class="overflow-x-auto scrollbar"></div></div>
73
  </div>
 
74
  <div id="recordModal" class="modal">
75
  <div class="modal-content modal-content-lg">
76
  <h2 class="text-xl font-bold mb-4 text-white">Schedule Recording</h2>
77
  <form id="recordForm" class="space-y-4">
78
- <div><label class="text-xs text-gray-400">Source</label><select id="recSourceSelect" name="source_id" required></select></div>
79
- <div><label class="text-xs text-gray-400">Program Name</label><input name="original_name" placeholder="Anime Title EP01" required></div>
80
- <div><label class="text-xs text-gray-400">Channel ID / Stream URL</label><input name="channel_id" placeholder="GR_123 or http://..." required></div>
81
- <div class="grid grid-cols-2 gap-2"><div><label class="text-xs text-gray-400">Start (Unix)</label><input type="number" name="start" required></div><div><label class="text-xs text-gray-400">End (Unix)</label><input type="number" name="end" required></div></div>
82
- <div><label class="text-xs text-gray-400">Dataset Repo</label><input name="dataset_repo" placeholder="hariii22/gtt"></div>
83
- <div><label class="text-xs text-gray-400">Mode</label><select name="mode"><option value="remote">Remote Server (Send Command)</option><option value="local">Local HF Space (Direct Stream)</option></select></div>
84
- <div class="flex justify-end gap-2 mt-6"><button type="button" class="btn btn-secondary" onclick="closeModal('recordModal')">Cancel</button><button type="submit" class="btn btn-primary">Start Recording</button></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  </form>
86
  </div>
87
  </div>
 
88
  <div id="previewModal" class="modal">
89
  <div class="modal-content modal-content-xl">
90
- <div class="flex justify-between items-center mb-3"><h3 id="previewTitle" class="text-lg font-semibold text-white truncate pr-4"></h3><button class="btn btn-danger text-xs px-3 py-1" onclick="closeModal('previewModal')">Close</button></div>
 
 
 
91
  <video id="previewPlayer" controls preload="metadata" class="mt-2"></video>
92
  <p id="previewInfo" class="text-xs text-gray-400 mt-2"></p>
93
  </div>
94
  </div>
95
- </div>
96
  <script>
97
- const API = window.location.href.replace(/\/$/, "");
98
- let authToken = localStorage.getItem("tvauth_token");
99
- function setAuthHeader(h){if(authToken)h["Authorization"]=`Bearer ${authToken}`;return h}
100
- async function apiFetch(url,opt={}){opt.headers=setAuthHeader(opt.headers||{});const res=await fetch(url,opt);if(res.status===401){doLogout();return null}return res}
101
- function showLogin(){document.getElementById("loginPage").style.display="flex";document.getElementById("mainApp").style.display="none";authToken=null;localStorage.removeItem("tvauth_token")}
102
- function showApp(user){document.getElementById("loginPage").style.display="none";document.getElementById("mainApp").style.display="block";document.getElementById("userInfo").textContent=`👤 ${user}`}
103
- function doLogout(){localStorage.removeItem("tvauth_token");authToken=null;apiFetch(`${API}/api/logout`,{method:"POST"});showLogin()}
104
- document.getElementById("loginForm").addEventListener("submit",async e=>{e.preventDefault();const u=document.getElementById("loginUser").value;const p=document.getElementById("loginPass").value;const err=document.getElementById("loginError");err.classList.add("hidden");try{const res=await fetch(`${API}/api/login`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:u,password:p})});if(!res.ok)throw new Error("Login failed");const data=await res.json();authToken=data.token;localStorage.setItem("tvauth_token",authToken);showApp(u);loadSources();loadEPG();loadRecordings();loadSourceSelects()}catch(e){err.textContent="Invalid credentials";err.classList.remove("hidden")}});
105
- if(authToken){apiFetch(`${API}/api/sources`).then(r=>{if(r&&r.ok){showApp("admin");loadSources();loadEPG();loadRecordings();loadSourceSelects()}else{showLogin()}}).catch(()=>showLogin())}
106
- document.querySelectorAll('[data-tab]').forEach(btn=>{btn.addEventListener('click',()=>{document.querySelectorAll('[data-tab]').forEach(b=>{b.classList.remove('tab-active');b.classList.add('text-gray-400')});document.querySelectorAll('.tab-content').forEach(c=>c.classList.add('hidden'));btn.classList.add('tab-active');btn.classList.remove('text-gray-400');document.getElementById(`tab-${btn.dataset.tab}`).classList.remove('hidden')})});
107
- function openModal(id){document.getElementById(id).classList.add('active');loadSourceSelects()}
108
- function closeModal(id){const m=document.getElementById(id);m.classList.remove('active');const v=document.getElementById('previewPlayer');if(v){v.pause();v.src=''}}
109
- function loadSourceSelects(){apiFetch(`${API}/api/sources`).then(r=>r&&r.json()).then(d=>{if(!d)return;const o=d.length?d.map(s=>`<option value="${s.id}">${s.name} (${s.type})</option>`).join(''):'<option disabled>No sources</option>';document.getElementById('epgSourceSelect').innerHTML=o;document.getElementById('recSourceSelect').innerHTML=o})}
110
- function renderTable(cid,h,rows){const c=document.getElementById(cid);if(!rows.length){c.innerHTML='<p class="text-gray-500 p-4">No data.</p>';return}c.innerHTML=`<table><tr>${h.map(x=>`<th>${x}</th>`).join('')}</tr>${rows.join('')}</table>`}
111
- async function loadSources(){const r=await apiFetch(`${API}/api/sources`);if(!r)return;const d=await r.json();renderTable('sourceTableContainer',['Name','Type','URL','Status'],d.map(s=>`<tr><td class="font-medium">${s.name}</td><td><span class="badge bg-blue-900 text-blue-200">${s.type}</span></td><td class="font-mono text-xs truncate max-w-[180px]">${s.url}</td><td><span class="text-green-400">Active</span></td></tr>`))}
112
- async function loadEPG(){const s=document.getElementById('epgSourceSelect').value;if(!s)return;const c=document.getElementById('epgTableContainer');c.innerHTML='<p class="p-4 text-blue-400">Loading...</p>';try{const r=await apiFetch(`${API}/api/epg/${s}`);if(!r)throw new Error();const d=await r.json();renderTable('epgTableContainer',['Title','Channel','Start','End','Action'],d.slice(0,50).map(p=>`<tr><td class="font-medium">${p.title||'Unknown'}</td><td class="text-gray-400">${p.channel_name||'N/A'}</td><td>${new Date(p.start*1000).toLocaleString()}</td><td>${new Date(p.end*1000).toLocaleString()}</td><td><button class="btn btn-primary text-xs px-2 py-1" onclick="quickRecord('${s}','${p.title.replace(/'/g,"\\'")}','${p.channel_id}',${p.start},${p.end})">Record</button></td></tr>`))}catch(e){c.innerHTML='<p class="p-4 text-red-400">Failed</p>'}}
113
- function quickRecord(s,n,c,st,en){document.getElementById('recSourceSelect').value=s;const f=document.getElementById('recordForm');f.original_name.value=n;f.channel_id.value=c;f.start.value=st;f.end.value=en;openModal('recordModal')}
114
- async function loadRecordings(){const r=await apiFetch(`${API}/api/recordings`);if(!r)return;const d=await r.json();renderTable('recordingTableContainer',['Program','MD5','Status','Time','Actions'],d.map(rec=>{const b=`<span class="badge status-${rec.status.toLowerCase()}">${rec.status}</span>`;const preview=(rec.status==='recording'||rec.status==='completed')?`<button class="text-blue-400 hover:underline mr-3" onclick="playVideo('${rec.id}','${rec.original_name.replace(/'/g,"\\'")}')">Preview</button>`:'';return `<tr><td class="font-medium">${rec.original_name}</td><td class="font-mono text-xs text-gray-400">${rec.md5_name}</td><td>${b}</td><td class="text-xs text-gray-400">${rec.start_time?new Date(rec.start_time*1000).toLocaleString():'-'}</td><td>${preview}<button class="btn btn-danger text-xs px-2 py-1" onclick="delRec('${rec.id}')">Del</button></td></tr>`}))}
115
- function playVideo(id,name){document.getElementById('previewTitle').textContent=name;const v=document.getElementById('previewPlayer');v.src=`${API}/api/stream/${id}?token=${authToken}`;v.load();v.onloadedmetadata=function(){document.getElementById('previewInfo').textContent=`Duration: ${Math.floor(v.duration/60)}:${String(Math.floor(v.duration%60)).padStart(2,'0')}`};openModal('previewModal')}
116
- async function delRec(id){if(!confirm('Delete recording?'))return;await apiFetch(`${API}/api/recordings/${id}`,{method:'DELETE'});loadRecordings()}
117
- document.getElementById('recordForm').addEventListener('submit',async e=>{e.preventDefault();const f=Object.fromEntries(new FormData(e.target));const res=await apiFetch(`${API}/api/record`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(f)});if(res&&res.ok){closeModal('recordModal');e.target.reset();loadRecordings()}})
118
- document.getElementById('previewModal').addEventListener('click',e=>{if(e.target.id==='previewModal')closeModal('previewModal')})
119
- setInterval(loadRecordings,8000)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  </script>
121
  </body>
122
  </html>
 
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
  <title>TV Archive Manager</title>
7
  <script src="https://cdn.tailwindcss.com"></script>
8
+ <script>
9
+ tailwind.config = {
10
+ theme: {
11
+ extend: {
12
+ colors: {
13
+ dark: { 900: '#0b1120', 800: '#111827', 700: '#1f2937', 600: '#374151' }
14
+ }
15
+ }
16
+ }
17
+ }
18
+ </script>
19
  <style>
20
+ body { background: #0b1120; color: #e2e8f0; font-family: system-ui, sans-serif; }
21
+ .glass { background: rgba(17, 24, 39, 0.8); backdrop-filter: blur(12px); border: 1px solid rgba(55, 65, 81, 0.5); }
22
+ .tab-active { border-bottom: 2px solid #3b82f6; color: #60a5fa; font-weight: 600; }
23
+ table { border-collapse: collapse; width: 100%; }
24
+ th, td { padding: 12px; text-align: left; border-bottom: 1px solid #374151; }
25
+ th { background: #111827; font-weight: 500; color: #9ca3af; }
26
+ tr:hover { background: #1f2937; }
27
+ .badge { padding: 4px 8px; border-radius: 999px; font-size: 0.7rem; font-weight: 500; text-transform: uppercase; }
28
+ .status-scheduled { background: #1d4ed8; color: #bfdbfe; }
29
+ .status-recording { background: #b45309; color: #fef3c7; animation: pulse 2s infinite; }
30
+ .status-completed { background: #047857; color: #d1fae5; }
31
+ @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.7; } }
32
+ .btn { padding: 8px 16px; border-radius: 6px; cursor: pointer; transition: all 0.2s; font-weight: 500; }
33
+ .btn-primary { background: #2563eb; } .btn-primary:hover { background: #1d4ed8; }
34
+ .btn-danger { background: #dc2626; } .btn-danger:hover { background: #b91c1c; }
35
+ .btn-secondary { background: #4b5563; } .btn-secondary:hover { background: #374151; }
36
+ input, select { width: 100%; padding: 10px; background: #111827; border: 1px solid #374151; border-radius: 6px; color: #e2e8f0; outline: none; }
37
+ input:focus, select:focus { border-color: #3b82f6; }
38
+ .modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.9); align-items: center; justify-content: center; z-index: 50; }
39
+ .modal.active { display: flex; }
40
+ .modal-content { background: #111827; padding: 24px; border-radius: 12px; width: 400px; max-width: 95%; border: 1px solid #374151; }
41
+ .modal-content-lg { width: 520px; }
42
+ .modal-content-xl { width: 640px; max-width: 95%; }
43
+ .scrollbar::-webkit-scrollbar { width: 6px; }
44
+ .scrollbar::-webkit-scrollbar-thumb { background: #374151; border-radius: 3px; }
45
+ video { width: 100%; border-radius: 8px; background: #000; outline: none; }
46
+ video::-webkit-media-controls-panel { background: rgba(17, 24, 39, 0.9); }
47
+ #loginPage { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: #0b1120; display: flex; align-items: center; justify-content: center; z-index: 100; }
48
+ #mainApp { display: none; }
49
  </style>
50
  </head>
51
  <body>
 
53
  <div class="modal-content">
54
  <h2 class="text-xl font-bold mb-6 text-center text-white">TV Archive Manager</h2>
55
  <form id="loginForm" class="space-y-4">
56
+ <div>
57
+ <label class="text-xs text-gray-400">Username</label>
58
+ <input id="loginUser" type="text" placeholder="admin" required autocomplete="username">
59
+ </div>
60
+ <div>
61
+ <label class="text-xs text-gray-400">Password</label>
62
+ <input id="loginPass" type="password" placeholder="••••••••" required autocomplete="current-password">
63
+ </div>
64
  <p id="loginError" class="text-red-400 text-sm hidden"></p>
65
  <button type="submit" class="btn btn-primary w-full">Sign In</button>
66
  </form>
67
  </div>
68
  </div>
69
+
70
  <div id="mainApp" class="p-4 md:p-8 min-h-screen">
71
+ <header class="flex flex-col md:flex-row justify-between items-center mb-8 gap-4">
72
+ <h1 class="text-2xl font-bold tracking-tight text-white">TV Archive Manager</h1>
73
+ <div class="flex items-center gap-3">
74
+ <span id="userInfo" class="text-sm text-gray-400"></span>
75
+ <button class="btn btn-secondary text-sm" onclick="doLogout()">Logout</button>
76
+ <button class="btn btn-primary" onclick="openModal('recordModal')">+ Schedule Record</button>
77
+ </div>
78
+ </header>
79
+ <div class="glass rounded-xl p-1 md:p-4 mb-6 shadow-xl">
80
+ <nav class="flex border-b border-gray-700 mb-4 px-2">
81
+ <button class="tab-active px-4 py-3" data-tab="sources">Sources</button>
82
+ <button class="px-4 py-3 text-gray-400 hover:text-blue-400" data-tab="epg">EPG Guide</button>
83
+ <button class="px-4 py-3 text-gray-400 hover:text-blue-400" data-tab="recordings">Recordings</button>
84
+ </nav>
85
+ <div id="tab-sources" class="tab-content p-2">
86
+ <div id="sourceTableContainer" class="overflow-x-auto scrollbar"></div>
87
+ </div>
88
+ <div id="tab-epg" class="tab-content hidden p-2">
89
+ <div class="mb-4 flex flex-col sm:flex-row gap-2">
90
+ <select id="epgSourceSelect" class="sm:w-64"></select>
91
+ <button class="btn btn-primary" onclick="loadEPG()">Refresh EPG</button>
92
+ </div>
93
+ <div id="epgTableContainer" class="overflow-x-auto scrollbar h-96"></div>
94
+ </div>
95
+ <div id="tab-recordings" class="tab-content hidden p-2">
96
+ <div class="overflow-x-auto scrollbar" id="recordingTableContainer"></div>
97
+ </div>
98
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
99
  </div>
100
+
101
  <div id="recordModal" class="modal">
102
  <div class="modal-content modal-content-lg">
103
  <h2 class="text-xl font-bold mb-4 text-white">Schedule Recording</h2>
104
  <form id="recordForm" class="space-y-4">
105
+ <div>
106
+ <label class="text-xs text-gray-400">Source</label>
107
+ <select id="recSourceSelect" name="source_id" required></select>
108
+ </div>
109
+ <div>
110
+ <label class="text-xs text-gray-400">Program Name</label>
111
+ <input name="original_name" placeholder="Anime Title EP01" required>
112
+ </div>
113
+ <div>
114
+ <label class="text-xs text-gray-400">Channel ID / Stream URL</label>
115
+ <input name="channel_id" placeholder="GR_123 or http://..." required>
116
+ </div>
117
+ <div class="grid grid-cols-2 gap-2">
118
+ <div>
119
+ <label class="text-xs text-gray-400">Start (Unix)</label>
120
+ <input type="number" name="start" required>
121
+ </div>
122
+ <div>
123
+ <label class="text-xs text-gray-400">End (Unix)</label>
124
+ <input type="number" name="end" required>
125
+ </div>
126
+ </div>
127
+ <div>
128
+ <label class="text-xs text-gray-400">Dataset Repo</label>
129
+ <input name="dataset_repo" placeholder="hariii22/gtt">
130
+ </div>
131
+ <div>
132
+ <label class="text-xs text-gray-400">Mode</label>
133
+ <select name="mode">
134
+ <option value="remote">Remote Server (Send Command)</option>
135
+ <option value="local">Local HF Space (Direct Stream)</option>
136
+ </select>
137
+ </div>
138
+ <div class="flex justify-end gap-2 mt-6">
139
+ <button type="button" class="btn btn-secondary" onclick="closeModal('recordModal')">Cancel</button>
140
+ <button type="submit" class="btn btn-primary">Start Recording</button>
141
+ </div>
142
  </form>
143
  </div>
144
  </div>
145
+
146
  <div id="previewModal" class="modal">
147
  <div class="modal-content modal-content-xl">
148
+ <div class="flex justify-between items-center mb-3">
149
+ <h3 id="previewTitle" class="text-lg font-semibold text-white truncate pr-4"></h3>
150
+ <button class="btn btn-danger text-xs px-3 py-1" onclick="closeModal('previewModal')">Close</button>
151
+ </div>
152
  <video id="previewPlayer" controls preload="metadata" class="mt-2"></video>
153
  <p id="previewInfo" class="text-xs text-gray-400 mt-2"></p>
154
  </div>
155
  </div>
156
+
157
  <script>
158
+ const API = window.location.href.replace(/\/$/, "");
159
+ let authToken = localStorage.getItem("tvauth_token");
160
+
161
+ function setAuthHeader(h) {
162
+ if (authToken) h["Authorization"] = `Bearer ${authToken}`;
163
+ return h;
164
+ }
165
+
166
+ async function apiFetch(url, opt = {}) {
167
+ opt.headers = setAuthHeader(opt.headers || {});
168
+ const res = await fetch(url, opt);
169
+ if (res.status === 401) {
170
+ doLogout();
171
+ return null;
172
+ }
173
+ return res;
174
+ }
175
+
176
+ function showLogin() {
177
+ document.getElementById("loginPage").style.display = "flex";
178
+ document.getElementById("mainApp").style.display = "none";
179
+ authToken = null;
180
+ localStorage.removeItem("tvauth_token");
181
+ }
182
+
183
+ function showApp(user) {
184
+ document.getElementById("loginPage").style.display = "none";
185
+ document.getElementById("mainApp").style.display = "block";
186
+ document.getElementById("userInfo").textContent = `👤 ${user}`;
187
+ }
188
+
189
+ function doLogout() {
190
+ localStorage.removeItem("tvauth_token");
191
+ authToken = null;
192
+ apiFetch(`${API}/api/logout`, { method: "POST" });
193
+ showLogin();
194
+ }
195
+
196
+ document.getElementById("loginForm").addEventListener("submit", async e => {
197
+ e.preventDefault();
198
+ const u = document.getElementById("loginUser").value;
199
+ const p = document.getElementById("loginPass").value;
200
+ const err = document.getElementById("loginError");
201
+ err.classList.add("hidden");
202
+ try {
203
+ const res = await fetch(`${API}/api/login`, {
204
+ method: "POST",
205
+ headers: { "Content-Type": "application/json" },
206
+ body: JSON.stringify({ username: u, password: p })
207
+ });
208
+ if (!res.ok) throw new Error("Login failed");
209
+ const data = await res.json();
210
+ authToken = data.token;
211
+ localStorage.setItem("tvauth_token", authToken);
212
+ showApp(u);
213
+ loadSources();
214
+ loadEPG();
215
+ loadRecordings();
216
+ loadSourceSelects();
217
+ } catch (e) {
218
+ err.textContent = "Invalid credentials";
219
+ err.classList.remove("hidden");
220
+ }
221
+ });
222
+
223
+ if (authToken) {
224
+ apiFetch(`${API}/api/sources`).then(r => {
225
+ if (r && r.ok) {
226
+ showApp("admin");
227
+ loadSources();
228
+ loadEPG();
229
+ loadRecordings();
230
+ loadSourceSelects();
231
+ } else {
232
+ showLogin();
233
+ }
234
+ }).catch(() => showLogin());
235
+ }
236
+
237
+ document.querySelectorAll('[data-tab]').forEach(btn => {
238
+ btn.addEventListener('click', () => {
239
+ document.querySelectorAll('[data-tab]').forEach(b => {
240
+ b.classList.remove('tab-active');
241
+ b.classList.add('text-gray-400');
242
+ });
243
+ document.querySelectorAll('.tab-content').forEach(c => c.classList.add('hidden'));
244
+ btn.classList.add('tab-active');
245
+ btn.classList.remove('text-gray-400');
246
+ document.getElementById(`tab-${btn.dataset.tab}`).classList.remove('hidden');
247
+ });
248
+ });
249
+
250
+ function openModal(id) {
251
+ document.getElementById(id).classList.add('active');
252
+ loadSourceSelects();
253
+ }
254
+
255
+ function closeModal(id) {
256
+ const m = document.getElementById(id);
257
+ m.classList.remove('active');
258
+ const v = document.getElementById('previewPlayer');
259
+ if (v) {
260
+ v.pause();
261
+ v.src = '';
262
+ }
263
+ }
264
+
265
+ function loadSourceSelects() {
266
+ apiFetch(`${API}/api/sources`).then(r => r && r.json()).then(d => {
267
+ if (!d) return;
268
+ const o = d.length ? d.map(s => `<option value="${s.id}">${s.name} (${s.type})</option>`).join('') : '<option disabled>No sources</option>';
269
+ document.getElementById('epgSourceSelect').innerHTML = o;
270
+ document.getElementById('recSourceSelect').innerHTML = o;
271
+ });
272
+ }
273
+
274
+ function renderTable(cid, h, rows) {
275
+ const c = document.getElementById(cid);
276
+ if (!rows.length) {
277
+ c.innerHTML = '<p class="text-gray-500 p-4">No data available.</p>';
278
+ return;
279
+ }
280
+ c.innerHTML = `<table><tr>${h.map(x => `<th>${x}</th>`).join('')}</tr>${rows.join('')}</table>`;
281
+ }
282
+
283
+ async function loadSources() {
284
+ const r = await apiFetch(`${API}/api/sources`);
285
+ if (!r) return;
286
+ const d = await r.json();
287
+ renderTable('sourceTableContainer', ['Name', 'Type', 'URL', 'Status', 'Test'], d.map(s => `
288
+ <tr>
289
+ <td class="font-medium">${s.name}</td>
290
+ <td><span class="badge bg-blue-900 text-blue-200">${s.type}</span></td>
291
+ <td class="font-mono text-xs truncate max-w-[180px]">${s.url}</td>
292
+ <td><span class="text-green-400">Active</span></td>
293
+ <td><button class="btn btn-secondary text-xs px-2 py-1" onclick="testSource('${s.id}')">Test</button></td>
294
+ </tr>`));
295
+ }
296
+
297
+ async function testSource(id) {
298
+ try {
299
+ const r = await apiFetch(`${API}/api/test-connection/${id}`);
300
+ if (r && r.ok) {
301
+ const d = await r.json();
302
+ alert(`Connection OK!\nStatus: ${d.status_code}\nURL: ${d.url}`);
303
+ }
304
+ } catch (e) {
305
+ alert(`Connection Failed:\n${e.message}`);
306
+ }
307
+ }
308
+
309
+ async function loadEPG() {
310
+ const s = document.getElementById('epgSourceSelect').value;
311
+ if (!s) return;
312
+ const c = document.getElementById('epgTableContainer');
313
+ c.innerHTML = '<p class="p-4 text-blue-400">Loading...</p>';
314
+ try {
315
+ const r = await apiFetch(`${API}/api/epg/${s}`);
316
+ if (!r) throw new Error("Network error");
317
+ const d = await r.json();
318
+ if (!d || d.length === 0) {
319
+ c.innerHTML = '<p class="p-4 text-yellow-400">No EPG data available. The server might be unreachable or has no programs.</p>';
320
+ return;
321
+ }
322
+ renderTable('epgTableContainer', ['Title', 'Channel', 'Start', 'End', 'Action'], d.slice(0, 50).map(p => `
323
+ <tr>
324
+ <td class="font-medium">${p.title || 'Unknown'}</td>
325
+ <td class="text-gray-400">${p.channel_name || 'N/A'}</td>
326
+ <td>${new Date(p.start * 1000).toLocaleString()}</td>
327
+ <td>${new Date(p.end * 1000).toLocaleString()}</td>
328
+ <td><button class="btn btn-primary text-xs px-2 py-1" onclick="quickRecord('${s}', '${p.title.replace(/'/g, "\\'")}', '${p.channel_id}', ${p.start}, ${p.end})">Record</button></td>
329
+ </tr>`));
330
+ } catch (e) {
331
+ console.error('EPG load error:', e);
332
+ c.innerHTML = `<p class="p-4 text-red-400">Failed to load EPG. Check if the server is accessible from HF Space.</p><p class="p-2 text-xs text-gray-500">Error: ${e.message}</p>`;
333
+ }
334
+ }
335
+
336
+ function quickRecord(s, n, c, st, en) {
337
+ document.getElementById('recSourceSelect').value = s;
338
+ const f = document.getElementById('recordForm');
339
+ f.original_name.value = n;
340
+ f.channel_id.value = c;
341
+ f.start.value = st;
342
+ f.end.value = en;
343
+ openModal('recordModal');
344
+ }
345
+
346
+ async function loadRecordings() {
347
+ const r = await apiFetch(`${API}/api/recordings`);
348
+ if (!r) return;
349
+ const d = await r.json();
350
+ renderTable('recordingTableContainer', ['Program', 'MD5', 'Status', 'Time', 'Actions'], d.map(rec => {
351
+ const b = `<span class="badge status-${rec.status.toLowerCase()}">${rec.status}</span>`;
352
+ const preview = (rec.status === 'recording' || rec.status === 'completed') ?
353
+ `<button class="text-blue-400 hover:underline mr-3" onclick="playVideo('${rec.id}', '${rec.original_name.replace(/'/g, "\\'")}')">Preview</button>` : '';
354
+ return `
355
+ <tr>
356
+ <td class="font-medium">${rec.original_name}</td>
357
+ <td class="font-mono text-xs text-gray-400">${rec.md5_name}</td>
358
+ <td>${b}</td>
359
+ <td class="text-xs text-gray-400">${rec.start_time ? new Date(rec.start_time * 1000).toLocaleString() : '-'}</td>
360
+ <td>${preview}<button class="btn btn-danger text-xs px-2 py-1" onclick="delRec('${rec.id}')">Del</button></td>
361
+ </tr>`;
362
+ }));
363
+ }
364
+
365
+ function playVideo(id, name) {
366
+ document.getElementById('previewTitle').textContent = name;
367
+ const v = document.getElementById('previewPlayer');
368
+ v.src = `${API}/api/stream/${id}?token=${authToken}`;
369
+ v.load();
370
+ v.onloadedmetadata = function () {
371
+ document.getElementById('previewInfo').textContent = `Duration: ${Math.floor(v.duration / 60)}:${String(Math.floor(v.duration % 60)).padStart(2, '0')}`;
372
+ };
373
+ openModal('previewModal');
374
+ }
375
+
376
+ async function delRec(id) {
377
+ if (!confirm('Delete recording?')) return;
378
+ await apiFetch(`${API}/api/recordings/${id}`, { method: 'DELETE' });
379
+ loadRecordings();
380
+ }
381
+
382
+ document.getElementById('recordForm').addEventListener('submit', async e => {
383
+ e.preventDefault();
384
+ const f = Object.fromEntries(new FormData(e.target));
385
+ const res = await apiFetch(`${API}/api/record`, {
386
+ method: 'POST',
387
+ headers: { 'Content-Type': 'application/json' },
388
+ body: JSON.stringify(f)
389
+ });
390
+ if (res && res.ok) {
391
+ closeModal('recordModal');
392
+ e.target.reset();
393
+ loadRecordings();
394
+ }
395
+ });
396
+
397
+ document.getElementById('previewModal').addEventListener('click', e => {
398
+ if (e.target.id === 'previewModal') closeModal('previewModal');
399
+ });
400
+
401
+ setInterval(loadRecordings, 8000);
402
  </script>
403
  </body>
404
  </html>