/* Server-rendered config, read off (see dashboard.html). URL_BASE carries the reverse-proxy prefix so /api/* fetches still resolve when the app is mounted on a subpath via SCRIPT_NAME. */ const URL_BASE = document.body.dataset.urlBase || ""; const TIMEZONE = document.body.dataset.timezone || "UTC"; let sonarrSearchResult = null; let radarrSearchResult = null; /* ── Clock ── */ function updateClock() { const el = document.getElementById('header-clock'); if (!el) return; try { el.textContent = new Date().toLocaleTimeString('en-US', { timeZone: TIMEZONE, hour: '2-digit', minute: '2-digit' }); } catch (e) { el.textContent = new Date().toLocaleTimeString(); } } updateClock(); setInterval(updateClock, 1000); /* ── Discover: show more / show less ── */ function toggleMore(btn, listId) { const list = document.getElementById(listId); if (!list) return; const items = Array.from(list.children); const hidden = items.filter(el => el.classList.contains('is-hidden')); if (hidden.length) { hidden.forEach(el => el.classList.remove('is-hidden')); btn.textContent = 'Show less'; btn.setAttribute('aria-expanded', 'true'); } else { items.forEach((el, i) => { if (i >= 5) el.classList.add('is-hidden'); }); const n = items.length - 5; btn.textContent = 'Show ' + n + ' more'; btn.setAttribute('aria-expanded', 'false'); } } /* ── Add flow: first tap arms, second tap commits ── Adding queues a real download, so a single stray tap shouldn't do it. */ let armedBtn = null; let armedTimer = null; function disarm() { if (armedBtn) { armedBtn.classList.remove('armed'); armedBtn.textContent = '+ Add'; armedBtn = null; } clearTimeout(armedTimer); } function armAdd(btn) { if (armedBtn === btn) { disarm(); return doAdd(btn); } disarm(); armedBtn = btn; btn.classList.add('armed'); btn.textContent = 'Confirm?'; armedTimer = setTimeout(disarm, 4000); } function removeRtItem(btn) { const item = btn.closest('.rt-item'); if (!item) return; item.classList.add('removing'); setTimeout(() => { const list = item.parentElement; item.remove(); if (list) { let n = 0; list.querySelectorAll('.rt-item').forEach(el => { el.querySelector('.rt-rank').textContent = ++n; }); } }, 320); } async function doAdd(btn) { const title = btn.dataset.title; const target = btn.dataset.target; // 'sonarr' | 'radarr' btn.disabled = true; btn.textContent = 'Adding…'; try { const resp = await fetch(URL_BASE + '/add-to-' + target, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ title }) }); const data = await resp.json(); if ((resp.ok && data.success) || resp.status === 409) { removeRtItem(btn); } else { btn.disabled = false; btn.textContent = '+ Add'; alert('Failed to add "' + title + '": ' + (data.error || 'Unknown error')); } } catch (e) { btn.disabled = false; btn.textContent = '+ Add'; alert('Network error: ' + e.message); } } // A tap anywhere else cancels a pending confirm. document.addEventListener('click', e => { if (armedBtn && !e.target.closest('.btn-add')) disarm(); }); /* ── SABnzbd live polling (never cached server-side) ── */ const sabSpeed = document.getElementById('sab-speed'); const sabQueue = document.getElementById('sab-queue'); const sabRemaining = document.getElementById('sab-remaining'); const sabBadge = document.getElementById('sab-badge'); const sabTimeleft = document.getElementById('sab-timeleft'); const sabTimeleftRow = document.getElementById('sab-timeleft-row'); async function pollSabnzbd() { try { const resp = await fetch(URL_BASE + '/api/sabnzbd', { cache: 'no-store', credentials: 'include' }); if (!resp.ok) return; const d = await resp.json(); if (d.status !== 'ok') return; if (sabSpeed) sabSpeed.textContent = d.speed; if (sabQueue) sabQueue.textContent = d.queue_count; if (sabRemaining) sabRemaining.textContent = d.sizeleft; if (sabBadge) { const s = (d.sab_status || '').toLowerCase(); sabBadge.className = 'badge ' + (s === 'downloading' ? 'downloading' : s === 'paused' ? 'paused' : 'idle'); sabBadge.textContent = d.sab_status; } const active = (d.sab_status || '').toLowerCase() === 'downloading' && d.timeleft && d.timeleft !== '0:00:00'; if (sabTimeleftRow) sabTimeleftRow.style.display = active ? 'inline' : 'none'; if (sabTimeleft) sabTimeleft.textContent = d.timeleft; const pill = document.getElementById('svc-stat-sabnzbd'); if (pill) pill.textContent = d.speed; } catch (e) { /* transient network error — try again next tick */ } } pollSabnzbd(); setInterval(pollSabnzbd, 3000); /* ── RT descriptions arrive asynchronously from IMDB ── */ async function loadRtDescriptions() { try { const resp = await fetch(URL_BASE + '/api/rt/descriptions', { credentials: 'include' }); if (!resp.ok) return; const d = await resp.json(); for (const [title, desc] of Object.entries(d.descriptions || {})) { const item = document.querySelector(`.rt-item[data-rt-title="${CSS.escape(title)}"]`); if (!item || !desc) continue; const el = item.querySelector('.rt-desc'); if (!el || el.textContent.trim()) continue; // already filled el.textContent = desc.length > 180 ? desc.slice(0, 180) + '…' : desc; } if (d.pending) setTimeout(loadRtDescriptions, 4000); } catch (e) { /* silently skip */ } } setTimeout(loadRtDescriptions, 1500); /* ── Prowlarr indexer polling ── */ function updateServicePill(name, isUp) { const pill = document.getElementById('svc-' + name); const light = document.getElementById('svc-light-' + name); if (pill) { pill.classList.toggle('up', isUp); pill.classList.toggle('down', !isUp); } if (light) { light.classList.toggle('up', isUp); light.classList.toggle('down', !isUp); } } async function pollProwlarr() { try { const resp = await fetch(URL_BASE + '/api/prowlarr', { credentials: 'include' }); if (!resp.ok) return; const d = await resp.json(); if (d.status !== 'ok') return; const upEl = document.getElementById('prowlarr-up-count'); const totEl = document.getElementById('prowlarr-total'); if (upEl) upEl.textContent = d.up_count; if (totEl) totEl.textContent = d.total; d.indexers.forEach(idx => { const row = document.getElementById('idx-' + idx.id); const light = document.getElementById('idx-light-' + idx.id); if (row) { row.classList.toggle('up', idx.up); row.classList.toggle('down', !idx.up); } if (light) { light.classList.toggle('up', idx.up); light.classList.toggle('down', !idx.up); } }); updateServicePill('prowlarr', d.up_count === d.total && d.total > 0); const stat = document.getElementById('svc-stat-prowlarr'); if (stat) stat.textContent = d.up_count + '/' + d.total + ' up'; } catch (e) { /* silently skip */ } } pollProwlarr(); setInterval(pollProwlarr, 30000); /* ── Header service lights ── */ async function pollServices() { try { const resp = await fetch(URL_BASE + '/api/services', { credentials: 'include' }); if (!resp.ok) return; const d = await resp.json(); for (const [name, isUp] of Object.entries(d)) updateServicePill(name, isUp); } catch (e) { /* silently skip */ } } pollServices(); setInterval(pollServices, 60000); /* ── Tautulli live streams ── */ async function pollTautulli() { try { const resp = await fetch(URL_BASE + '/api/tautulli', { credentials: 'include' }); if (!resp.ok) return; const d = await resp.json(); if (d.status !== 'ok') return; const count = d.stream_count; const sessions = d.sessions || []; const stat = document.getElementById('svc-stat-tautulli'); if (stat) stat.textContent = count > 0 ? count + ' streaming' : '0 streams'; const countEl = document.getElementById('tautulli-count'); if (countEl) countEl.textContent = count + ' active'; const body = document.getElementById('now-playing'); if (!body) return; if (sessions.length) { body.innerHTML = sessions.map(s => { const icon = s.media_type === 'movie' ? '🎬' : '📺'; const esc = t => String(t).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); return `
${esc(s.title)} ${esc(s.user)}
`; }).join(''); } else { body.innerHTML = '

Nothing streaming right now.

'; } } catch (e) { /* silently skip */ } } pollTautulli(); setInterval(pollTautulli, 15000); /* ── Clear search results when the field is emptied ── */ document.getElementById('sonarr-search-input').addEventListener('input', function () { if (!this.value.trim()) { document.getElementById('sonarr-search-result').classList.remove('visible'); document.getElementById('sonarr-search-msg').style.display = 'none'; sonarrSearchResult = null; } }); document.getElementById('radarr-search-input').addEventListener('input', function () { if (!this.value.trim()) { document.getElementById('radarr-search-result').classList.remove('visible'); document.getElementById('radarr-search-msg').style.display = 'none'; radarrSearchResult = null; } }); /* ── Sonarr search ── */ async function searchSonarr() { const input = document.getElementById('sonarr-search-input'); const btn = document.getElementById('sonarr-search-btn'); const resultEl = document.getElementById('sonarr-search-result'); const msgEl = document.getElementById('sonarr-search-msg'); const title = input.value.trim(); if (!title) return; btn.disabled = true; btn.textContent = 'Searching…'; resultEl.classList.remove('visible'); msgEl.style.display = 'none'; sonarrSearchResult = null; try { const resp = await fetch(URL_BASE + '/api/sonarr/lookup?q=' + encodeURIComponent(title), { credentials: 'include' }); if (resp.redirected || !resp.headers.get('content-type')?.includes('application/json')) { msgEl.textContent = 'Session expired — please refresh the page.'; msgEl.className = 'search-msg error'; msgEl.style.display = 'block'; btn.disabled = false; btn.textContent = 'Search'; return; } const d = await resp.json(); if (!resp.ok || d.error) { msgEl.textContent = d.error || 'Search failed.'; msgEl.className = 'search-msg error'; msgEl.style.display = 'block'; } else if (!d.title) { msgEl.textContent = 'No results found.'; msgEl.className = 'search-msg'; msgEl.style.display = 'block'; } else { sonarrSearchResult = d; document.getElementById('sonarr-result-title').textContent = d.title; document.getElementById('sonarr-result-meta').textContent = (d.year || '') + (d.network ? ' · ' + d.network : '') + (d.status ? ' · ' + d.status : ''); const addBtn = document.getElementById('sonarr-add-btn'); if (d.exists) { addBtn.textContent = '✓ In library'; addBtn.disabled = true; addBtn.className = 'btn-add done'; } else { addBtn.textContent = '+ Add'; addBtn.disabled = false; addBtn.className = 'btn-add'; } resultEl.classList.add('visible'); } } catch (e) { msgEl.textContent = 'Search failed: ' + e.message; msgEl.className = 'search-msg error'; msgEl.style.display = 'block'; } btn.disabled = false; btn.textContent = 'Search'; } async function confirmAddSonarr() { if (!sonarrSearchResult) return; const addBtn = document.getElementById('sonarr-add-btn'); addBtn.disabled = true; addBtn.textContent = 'Adding…'; try { const resp = await fetch(URL_BASE + '/add-to-sonarr', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ title: sonarrSearchResult.title }) }); const d = await resp.json(); if ((resp.ok && d.success) || resp.status === 409) { addBtn.textContent = '✓ Added'; addBtn.className = 'btn-add done'; } else { addBtn.disabled = false; addBtn.textContent = '+ Add'; alert('Failed: ' + (d.error || 'Unknown error')); } } catch (e) { addBtn.disabled = false; addBtn.textContent = '+ Add'; } } /* ── Radarr search ── */ async function searchRadarr() { const input = document.getElementById('radarr-search-input'); const btn = document.getElementById('radarr-search-btn'); const resultEl = document.getElementById('radarr-search-result'); const msgEl = document.getElementById('radarr-search-msg'); const title = input.value.trim(); if (!title) return; btn.disabled = true; btn.textContent = 'Searching…'; resultEl.classList.remove('visible'); msgEl.style.display = 'none'; radarrSearchResult = null; try { const resp = await fetch(URL_BASE + '/api/radarr/lookup?q=' + encodeURIComponent(title), { credentials: 'include' }); if (resp.redirected || !resp.headers.get('content-type')?.includes('application/json')) { msgEl.textContent = 'Session expired — please refresh the page.'; msgEl.className = 'search-msg error'; msgEl.style.display = 'block'; btn.disabled = false; btn.textContent = 'Search'; return; } const d = await resp.json(); if (!resp.ok || d.error) { msgEl.textContent = d.error || 'Search failed.'; msgEl.className = 'search-msg error'; msgEl.style.display = 'block'; } else if (!d.title) { msgEl.textContent = 'No results found.'; msgEl.className = 'search-msg'; msgEl.style.display = 'block'; } else { radarrSearchResult = d; document.getElementById('radarr-result-title').textContent = d.title; document.getElementById('radarr-result-meta').textContent = (d.year || '') + (d.studio ? ' · ' + d.studio : '') + (d.runtime ? ' · ' + d.runtime + ' min' : ''); const addBtn = document.getElementById('radarr-add-btn'); if (d.exists) { addBtn.textContent = '✓ In library'; addBtn.disabled = true; addBtn.className = 'btn-add done'; } else { addBtn.textContent = '+ Add'; addBtn.disabled = false; addBtn.className = 'btn-add'; } resultEl.classList.add('visible'); } } catch (e) { msgEl.textContent = 'Search failed: ' + e.message; msgEl.className = 'search-msg error'; msgEl.style.display = 'block'; } btn.disabled = false; btn.textContent = 'Search'; } async function confirmAddRadarr() { if (!radarrSearchResult) return; const addBtn = document.getElementById('radarr-add-btn'); addBtn.disabled = true; addBtn.textContent = 'Adding…'; try { const resp = await fetch(URL_BASE + '/add-to-radarr', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ title: radarrSearchResult.title }) }); const d = await resp.json(); if ((resp.ok && d.success) || resp.status === 409) { addBtn.textContent = '✓ Added'; addBtn.className = 'btn-add done'; } else { addBtn.disabled = false; addBtn.textContent = '+ Add'; alert('Failed: ' + (d.error || 'Unknown error')); } } catch (e) { addBtn.disabled = false; addBtn.textContent = '+ Add'; } }