Files
embycovers/templates/favorites.html
T

660 lines
31 KiB
HTML
Raw Normal View History

2026-06-08 00:01:55 +12:00
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>EmbyToolkit — User Favourites</title>
<link rel="stylesheet" href="/static/app-theme.css">
<style>
:root {
--bg: #0d0f12;
--surface: #13171f;
--surface2: #1a2030;
--surface3: #222840;
--border: #252d3d;
--border-active: #14b8a6;
--text: #e8ecf4;
--text-2: #8892a8;
--text-3: #4e5a70;
--accent: #14b8a6;
--accent-h: #2dd4bf;
--accent-glow: rgba(20,184,166,0.12);
--green: #34d399;
--green-bg: rgba(52,211,153,0.08);
--red: #f87171;
--r: 10px;
--r-lg: 14px;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: 'Inter', system-ui, sans-serif;
display: grid;
grid-template-columns: 240px 1fr;
height: 100vh;
height: 100svh;
overflow: hidden;
}
/* ── Mobile top bar ── */
.app-topbar {
display: none;
align-items: center;
gap: 12px;
padding: 10px 14px;
background: var(--surface);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 30;
}
.app-topbar-brand { display: flex; align-items: center; gap: 10px; font-weight: 700; letter-spacing: -0.03em; }
.icon-btn {
width: 38px; height: 38px;
display: inline-grid; place-items: center;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--surface2);
color: var(--text);
cursor: pointer;
}
.icon-btn:hover { background: var(--surface3); }
.nav-scrim {
display: none;
position: fixed; inset: 0;
background: rgba(0,0,0,0.55);
z-index: 40; opacity: 0;
transition: opacity 0.18s ease;
}
.nav-scrim.show { opacity: 1; }
/* ── App Nav Sidebar ── */
.app-nav { display: flex; flex-direction: column; background: var(--surface); border-right: 1px solid var(--border); overflow: hidden; }
.app-nav-brand { padding: 18px 16px; display: flex; align-items: center; gap: 10px; border-bottom: 1px solid var(--border); flex-shrink: 0; }
.app-nav-logo { width: 32px; height: 32px; background: var(--accent); border-radius: 8px; display: grid; place-items: center; color: #fff; flex-shrink: 0; }
.app-nav-name { font-size: 15px; font-weight: 700; letter-spacing: -0.03em; }
.app-nav-items { list-style: none; padding: 8px; flex: 1; overflow-y: auto; margin: 0; }
.app-nav-item { display: flex; align-items: center; gap: 10px; padding: 9px 12px; border-radius: 8px; color: var(--text-2); text-decoration: none; font-size: 13px; font-weight: 500; transition: background 0.12s, color 0.12s; margin-bottom: 1px; }
.app-nav-item:hover { background: var(--surface2); color: var(--text); }
.app-nav-item.active { background: var(--accent); color: #fff; }
.app-nav-footer { padding: 14px 16px; border-top: 1px solid var(--border); display: flex; align-items: center; gap: 7px; font-size: 12px; color: var(--text-2); flex-shrink: 0; }
.dot { width: 7px; height: 7px; border-radius: 50%; background: var(--green); box-shadow: 0 0 6px var(--green); }
.dot.off { background: var(--red); box-shadow: 0 0 6px var(--red); }
/* ── Page ── */
.page { padding: 24px clamp(16px, 2.4vw, 36px) 32px; overflow-y: auto; }
.page-inner { width: 100%; max-width: 1680px; margin: 0 auto; }
.hero { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 18px; }
.hero-copy h2 { margin: 0 0 8px; font-size: clamp(24px, 3vw, 34px); line-height: 1; letter-spacing: -0.04em; }
.hero-copy p { margin: 0; max-width: 820px; color: var(--text-2); font-size: 14px; line-height: 1.5; }
/* ── Sections ── */
.section { margin-bottom: 18px; }
.section-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-3); margin: 0 0 10px 2px; }
.user-picker { display: flex; flex-wrap: wrap; gap: 8px; }
.user-chip {
display: inline-flex; align-items: center; gap: 9px;
padding: 8px 14px 8px 10px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--surface2);
color: var(--text-2);
font-family: inherit; font-size: 13px; font-weight: 600;
cursor: pointer;
transition: background 0.14s, border-color 0.14s, color 0.14s;
}
.user-chip:hover { background: var(--surface3); color: var(--text); }
.user-chip .avatar { width: 26px; height: 26px; font-size: 10px; }
.user-chip .count { font-size: 11px; color: var(--text-3); font-variant-numeric: tabular-nums; }
.user-chip.active { background: var(--accent-glow); border-color: var(--accent); color: var(--text); }
.user-chip .pl-ico { display: inline-grid; place-items: center; color: var(--text-3); }
.user-chip.active .pl-ico { color: var(--accent-h); }
.chip-badge {
font-size: 9px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase;
padding: 2px 7px; border-radius: 999px;
background: var(--accent-glow); color: var(--accent-h); border: 1px solid rgba(54,214,224,0.3);
}
.actions-note {
margin-top: 10px; font-size: 12px; color: #f6d98c;
background: rgba(243,201,105,0.08); border: 1px solid rgba(243,201,105,0.25);
border-radius: 10px; padding: 8px 12px;
}
.actions-bar {
display: flex; flex-wrap: wrap; align-items: center; gap: 10px;
padding: 14px 16px;
border: 1px solid var(--border);
border-radius: var(--r-lg);
background: linear-gradient(180deg, rgba(26,32,48,0.95), rgba(19,23,31,0.95));
}
.actions-group { display: flex; align-items: center; gap: 8px; }
.actions-sep { width: 1px; align-self: stretch; background: var(--border); margin: 2px 4px; }
.actions-spacer { flex: 1; }
.target-field { display: inline-flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-2); }
.target-field input {
width: 64px; padding: 7px 9px; border-radius: 9px;
border: 1px solid var(--border); background: var(--surface2); color: var(--text);
font-family: inherit; font-size: 13px; font-variant-numeric: tabular-nums; text-align: center;
}
.btn {
min-height: 36px; padding: 0 14px; border-radius: 10px;
border: 1px solid var(--border); background: var(--surface2); color: var(--text);
font-family: inherit; font-size: 13px; font-weight: 600; cursor: pointer;
display: inline-flex; align-items: center; gap: 7px;
transition: background 0.12s, border-color 0.12s, opacity 0.12s;
}
.btn:hover:not(:disabled) { background: var(--surface3); }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-primary { background: var(--accent); border-color: var(--accent); color: #04181b; }
.btn-danger { color: #ffb4b2; border-color: rgba(240,114,111,0.35); background: rgba(240,114,111,0.12); }
.btn-green { background: var(--green); border-color: var(--green); color: #08120e; }
.stat-row { margin-bottom: 18px; }
.panel {
border: 1px solid var(--border);
border-radius: var(--r-lg);
background: linear-gradient(180deg, rgba(19,23,31,0.98), rgba(13,15,18,0.98));
overflow: hidden;
}
.panel-head {
display: flex; align-items: center; justify-content: space-between; gap: 12px;
padding: 13px 16px; border-bottom: 1px solid var(--border);
}
.panel-head h3 { margin: 0; font-size: 14px; font-weight: 700; letter-spacing: -0.01em; }
.panel-head .muted { font-size: 12px; color: var(--text-3); font-variant-numeric: tabular-nums; }
.table-scroll { overflow-x: auto; }
.item-id { font-size: 11px; color: var(--text-3); font-variant-numeric: tabular-nums; font-family: ui-monospace, "SF Mono", Menlo, monospace; }
.preview-card {
margin-top: 14px;
border: 1px solid var(--border);
border-radius: var(--r-lg);
background: var(--surface);
overflow: hidden;
}
.preview-head {
display: flex; align-items: center; gap: 10px;
padding: 12px 16px; border-bottom: 1px solid var(--border);
}
.preview-head h4 { margin: 0; font-size: 13px; font-weight: 700; }
.preview-flag {
font-size: 10px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase;
padding: 3px 9px; border-radius: 999px;
}
.preview-flag.dry { background: var(--amber-bg, rgba(243,201,105,0.12)); border: 1px solid rgba(243,201,105,0.3); color: #f6d98c; }
.preview-flag.applied { background: var(--green-bg); border: 1px solid rgba(70,217,154,0.3); color: #b7f0d4; }
.preview-body { padding: 6px 8px 10px; max-height: 320px; overflow-y: auto; }
.preview-list { list-style: none; margin: 0; padding: 0; }
.preview-list li {
display: flex; align-items: center; gap: 10px;
padding: 8px 10px; border-radius: 8px; font-size: 13px;
}
.preview-list li:hover { background: var(--surface2); }
.preview-list .pl-title { flex: 1; min-width: 0; color: var(--text); font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.preview-list .pl-score { font-size: 11px; color: var(--accent-h); font-variant-numeric: tabular-nums; }
.preview-list .pl-id { font-size: 11px; color: var(--text-3); font-variant-numeric: tabular-nums; }
.preview-empty { padding: 24px; text-align: center; color: var(--text-3); font-size: 13px; }
.empty {
padding: 54px 20px; border: 1px dashed var(--border); border-radius: var(--r-lg);
text-align: center; color: var(--text-3); font-size: 14px;
}
.toast {
position: fixed; bottom: 20px; right: 20px;
padding: 11px 18px; border-radius: 12px;
font-size: 13px; font-weight: 500; z-index: 200;
transform: translateY(80px); opacity: 0;
transition: all 0.25s cubic-bezier(0.4,0,0.2,1); pointer-events: none;
}
.toast.show { transform: translateY(0); opacity: 1; }
.spinner { width: 30px; height: 30px; border: 2.5px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.7s linear infinite; margin: 0 auto 12px; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (max-width: 980px) {
body { grid-template-columns: 1fr; }
.app-topbar { display: flex; }
.nav-scrim.show { display: block; }
.app-nav {
position: fixed; top: 0; bottom: 0; left: 0; width: 264px; z-index: 50;
transform: translateX(-100%); transition: transform 0.2s ease;
}
.app-nav.open { transform: translateX(0); }
}
</style>
</head>
<body>
<header class="app-topbar">
<button class="icon-btn" id="navToggle" aria-label="Open navigation">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<div class="app-topbar-brand"><span>EmbyToolkit</span></div>
</header>
<div class="nav-scrim" id="navScrim"></div>
<nav class="app-nav" id="appNav">
<div class="app-nav-brand">
<div class="app-nav-logo">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.29 7 12 12 20.71 7"/><line x1="12" y1="22" x2="12" y2="12"/></svg>
</div>
<span class="app-nav-name">EmbyToolkit</span>
</div>
<ul class="app-nav-items">
<li><a class="app-nav-item" href="/">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/></svg>
<span>Generator</span>
</a></li>
<li><a class="app-nav-item" href="/collections">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
<span>Collections</span>
</a></li>
<li><a class="app-nav-item" href="/airing">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="7" width="20" height="15" rx="2"/><polyline points="17 2 12 7 7 2"/></svg>
<span>Airing</span>
</a></li>
<li><a class="app-nav-item" href="/bulk-assign">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>
<span>Bulk Assign</span>
</a></li>
<li><a class="app-nav-item active" href="/favorites">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17.27 18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/></svg>
<span>User Favourites</span>
</a></li>
</ul>
<div class="app-nav-foot">
<div class="app-nav-status">
<span class="dot" id="statusDot"></span>
<span id="statusText">Checking…</span>
</div>
<div class="app-nav-version">EmbyToolkit · v1.0</div>
</div>
</nav>
<main class="page">
<div class="page-inner">
<section class="hero">
<div class="hero-copy">
<h2>User Favourites</h2>
<p>Browse any Emby collection and view it as a given user. For a user's own <strong>"{Name} Favorites"</strong> collection you can also remove items they have already watched and top it back up with recommendations built only from that user's watch history. Destructive and bulk actions preview first; nothing changes until you apply.</p>
</div>
</section>
<section class="section">
<p class="section-label">Collection</p>
<div class="user-picker" id="collectionPicker">
<div style="color:var(--text-3);font-size:13px;padding:6px 2px">Loading collections…</div>
</div>
</section>
<section class="section">
<p class="section-label">Watched status for</p>
<div class="user-picker" id="userPicker"></div>
</section>
<div id="workspace" style="display:none">
<section class="section stat-row" id="statRow">
<div class="stat-card">
<div class="stat-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg></div>
<div class="stat-meta"><span class="stat-value" id="statCurrent"></span><span class="stat-label">Current collection count</span></div>
</div>
<div class="stat-card">
<div class="stat-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg></div>
<div class="stat-meta"><span class="stat-value" id="statWatched"></span><span class="stat-label">Watched items found</span></div>
</div>
<div class="stat-card">
<div class="stat-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17.27 18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/></svg></div>
<div class="stat-meta"><span class="stat-value" id="statRecommended"></span><span class="stat-label">Items recommended</span></div>
</div>
<div class="stat-card">
<div class="stat-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg></div>
<div class="stat-meta"><span class="stat-value" id="statFinal"></span><span class="stat-label">Final count after apply</span></div>
</div>
</section>
<section class="section">
<div class="actions-bar">
<div class="actions-group">
<button class="btn" id="btnPreviewCleanup">Preview cleanup</button>
<button class="btn btn-danger" id="btnApplyCleanup" disabled>Apply cleanup</button>
</div>
<div class="actions-sep"></div>
<div class="actions-group">
<label class="target-field">Target size
<input type="number" id="targetSize" min="1" max="200" value="25">
</label>
<button class="btn" id="btnPreviewRegen">Preview regenerate</button>
<button class="btn btn-green" id="btnApplyRegen" disabled>Apply regenerate</button>
</div>
<div class="actions-spacer"></div>
<button class="btn" id="btnRefresh">Refresh</button>
</div>
<div class="actions-note" id="actionsNote" style="display:none"></div>
<div id="actionResult"></div>
</section>
<section class="section">
<div class="panel">
<div class="panel-head">
<h3 id="playlistTitle">Collection</h3>
<span class="muted" id="playlistMeta"></span>
</div>
<div class="table-scroll">
<table class="data-table">
<thead>
<tr>
<th>Title</th>
<th>Type</th>
<th>Year</th>
<th>Runtime</th>
<th>Watched</th>
<th>Emby item id</th>
</tr>
</thead>
<tbody id="playlistBody"></tbody>
</table>
</div>
<div id="playlistEmpty" class="preview-empty" style="display:none">This collection has no items.</div>
</div>
</section>
</div>
<div id="noUsers" class="empty" style="display:none">
No collections found. Create a collection named "<strong>{User} Favorites</strong>" in Emby (for example "Matt Favorites") and refresh.
</div>
</div>
</main>
<div class="toast" id="toast"></div>
<script>
const $ = s => document.querySelector(s);
const state = { collections: [], users: [], selectedCollectionId: null, selectedUserId: null, view: null };
/* ── nav (mobile) ── */
const appNav = $('#appNav'), navScrim = $('#navScrim'), navToggle = $('#navToggle');
function closeNav() { appNav.classList.remove('open'); navScrim.classList.remove('show'); }
if (navToggle) navToggle.addEventListener('click', () => { appNav.classList.add('open'); navScrim.classList.add('show'); });
if (navScrim) navScrim.addEventListener('click', closeNav);
/* ── helpers ── */
function toast(msg, ok = true) {
const t = $('#toast');
t.textContent = msg;
t.style.background = ok ? 'rgba(70,217,154,0.12)' : 'rgba(240,114,111,0.12)';
t.style.border = '1px solid ' + (ok ? 'rgba(70,217,154,0.4)' : 'rgba(240,114,111,0.4)');
t.style.color = ok ? '#b7f0d4' : '#ffb4b2';
t.classList.add('show');
clearTimeout(toast._t);
toast._t = setTimeout(() => t.classList.remove('show'), 3200);
}
function initials(name) { return (name || '?').trim().slice(0, 2).toUpperCase(); }
function escapeHtml(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c])); }
function fmtRuntime(min) { if (!min) return '—'; const h = Math.floor(min / 60), m = min % 60; return h ? `${h}h ${m}m` : `${m}m`; }
async function getJSON(url) {
const r = await fetch(url);
if (!r.ok) { const d = await r.json().catch(() => ({})); throw new Error(d.detail || `Request failed (${r.status})`); }
return r.json();
}
async function postJSON(url, body) {
const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!r.ok) { const d = await r.json().catch(() => ({})); throw new Error(d.detail || `Request failed (${r.status})`); }
return r.json();
}
/* ── data ── */
const ICON_FAV = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M12 17.27 18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/></svg>';
const ICON_COL = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>';
async function loadOverview() {
try {
const data = await getJSON('/api/favorites/collections');
state.collections = data.collections || [];
state.users = data.users || [];
if (!state.collections.length) {
$('#collectionPicker').innerHTML = '<div style="color:var(--text-3);font-size:13px;padding:6px 2px">No collections found in Emby.</div>';
$('#noUsers').style.display = 'block';
return;
}
$('#noUsers').style.display = 'none';
const def = state.collections.find(c => c.is_favorites) || state.collections[0];
renderCollections();
renderUsers();
selectCollection(def.collection_id);
} catch (e) {
$('#collectionPicker').innerHTML = `<div style="color:#ffb4b2;font-size:13px">${escapeHtml(e.message)}</div>`;
}
}
function currentCollection() { return state.collections.find(c => c.collection_id === state.selectedCollectionId); }
function actionsReady() { return !!(state.selectedUserId && state.view && state.view.actions_enabled); }
function renderCollections() {
const picker = $('#collectionPicker');
picker.innerHTML = state.collections.map(c => `
<button class="user-chip${c.collection_id === state.selectedCollectionId ? ' active' : ''}" data-id="${escapeHtml(c.collection_id)}">
<span class="pl-ico">${c.is_favorites ? ICON_FAV : ICON_COL}</span>
<span>${escapeHtml(c.collection_name)}</span>
${c.is_favorites ? '<span class="chip-badge">Favourites</span>' : ''}
${c.item_count != null ? `<span class="count">${c.item_count}</span>` : ''}
</button>`).join('');
picker.querySelectorAll('.user-chip').forEach(btn =>
btn.addEventListener('click', () => selectCollection(btn.dataset.id)));
}
function renderUsers() {
const picker = $('#userPicker');
picker.innerHTML = state.users.map(u => `
<button class="user-chip${u.id === state.selectedUserId ? ' active' : ''}" data-id="${escapeHtml(u.id)}">
<span class="avatar">${escapeHtml(initials(u.name))}</span>
<span>${escapeHtml(u.name)}</span>
</button>`).join('');
picker.querySelectorAll('.user-chip').forEach(btn =>
btn.addEventListener('click', () => selectUser(btn.dataset.id)));
}
function selectCollection(id) {
state.selectedCollectionId = id;
const col = currentCollection();
if (col && col.owner_user_id) state.selectedUserId = col.owner_user_id;
else if (!state.selectedUserId && state.users.length) state.selectedUserId = state.users[0].id;
renderCollections();
renderUsers();
$('#actionResult').innerHTML = '';
loadView();
}
function selectUser(id) {
state.selectedUserId = id;
renderUsers();
$('#actionResult').innerHTML = '';
loadView();
}
async function loadView() {
if (!state.selectedCollectionId || !state.selectedUserId) return;
$('#workspace').style.display = 'block';
$('#playlistBody').innerHTML = `<tr><td colspan="6"><div class="spinner"></div></td></tr>`;
try {
const view = await getJSON(`/api/favorites/collection/${state.selectedCollectionId}?user_id=${encodeURIComponent(state.selectedUserId)}`);
state.view = view;
renderItems();
renderSummary({ current: view.summary.current_count, watched: view.summary.watched_count, recommended: '—', final: '—' });
gateActions(view);
} catch (e) {
$('#playlistBody').innerHTML = `<tr><td colspan="6" style="color:#ffb4b2">${escapeHtml(e.message)}</td></tr>`;
toast(e.message, false);
}
}
function gateActions(view) {
const on = !!view.actions_enabled;
$('#btnPreviewCleanup').disabled = !on;
$('#btnPreviewRegen').disabled = !on;
if (!on) { $('#btnApplyCleanup').disabled = true; $('#btnApplyRegen').disabled = true; }
const note = $('#actionsNote');
if (on) { note.style.display = 'none'; note.textContent = ''; }
else { note.style.display = 'block'; note.textContent = view.actions_reason || 'Cleanup and regenerate are unavailable for this collection.'; }
}
function renderItems() {
const v = state.view;
$('#playlistTitle').textContent = v.collection_name;
$('#playlistMeta').textContent = `${v.summary.current_count} items · ${v.summary.watched_count} watched · as ${v.user_name}`;
const body = $('#playlistBody');
const empty = $('#playlistEmpty');
if (!v.items.length) {
body.innerHTML = '';
empty.style.display = 'block';
return;
}
empty.style.display = 'none';
body.innerHTML = v.items.map(it => `
<tr>
<td class="cell-strong">${escapeHtml(it.title)}${it.series_name ? `<div class="cell-sub">${escapeHtml(it.series_name)}</div>` : ''}</td>
<td>${escapeHtml(it.type || it.media_type || '—')}</td>
<td class="cell-num">${it.year || '—'}</td>
<td class="cell-num">${fmtRuntime(it.runtime_minutes)}</td>
<td>${it.watched
? '<span class="badge badge-watched">Watched</span>'
: '<span class="badge">Unwatched</span>'}</td>
<td><span class="item-id">${escapeHtml(it.id)}</span></td>
</tr>`).join('');
}
async function refreshCounts() {
try {
const data = await getJSON('/api/favorites/collections');
state.collections = data.collections || [];
renderCollections();
} catch { /* keep stale counts on failure */ }
}
function renderSummary({ current, watched, recommended, final }) {
$('#statCurrent').textContent = current;
$('#statWatched').textContent = watched;
$('#statRecommended').textContent = recommended;
$('#statFinal').textContent = final;
}
/* ── action result panel ── */
function renderActionResult(kind, payload) {
const dry = payload.dry_run;
const isCleanup = kind === 'cleanup';
const list = isCleanup ? (payload.removed || []) : (payload.recommended || []);
const verb = isCleanup ? (dry ? 'would be removed' : 'removed') : (dry ? 'would be added' : 'added');
const heading = isCleanup ? 'Watched-item cleanup' : 'Recommended replacements';
const rows = list.length ? `<ul class="preview-list">${list.map(i => `
<li>
<span class="pl-title">${escapeHtml(i.title)}</span>
${i.score != null ? `<span class="pl-score">score ${i.score}</span>` : ''}
<span class="pl-id">${escapeHtml(i.item_id)}</span>
</li>`).join('')}</ul>`
: `<div class="preview-empty">${payload.message ? escapeHtml(payload.message) : `Nothing ${verb}.`}</div>`;
$('#actionResult').innerHTML = `
<div class="preview-card">
<div class="preview-head">
<h4>${heading}</h4>
<span class="preview-flag ${dry ? 'dry' : 'applied'}">${dry ? 'Dry run · preview' : 'Applied'}</span>
<span style="margin-left:auto;font-size:12px;color:var(--text-3)">${list.length} ${list.length === 1 ? 'item' : 'items'} ${verb}</span>
</div>
<div class="preview-body">${rows}</div>
</div>`;
}
/* ── cleanup ── */
async function previewCleanup() {
if (!actionsReady()) return;
try {
const res = await postJSON(`/api/favorites/collection/${state.selectedCollectionId}/cleanup`, { userId: state.selectedUserId, dryRun: true });
renderActionResult('cleanup', res);
renderSummary({
current: res.summary.current_count, watched: res.summary.watched_count,
recommended: $('#statRecommended').textContent,
final: res.summary.current_count - res.summary.watched_count,
});
$('#btnApplyCleanup').disabled = res.summary.watched_count === 0;
toast(`${res.watched_found} watched item${res.watched_found === 1 ? '' : 's'} found`);
} catch (e) { toast(e.message, false); }
}
async function applyCleanup() {
if (!actionsReady()) return;
const who = (state.view && state.view.user_name) || 'this user';
const where = (state.view && state.view.collection_name) || 'this collection';
if (!confirm(`Remove items ${who} has watched from "${where}"? Collections are shared, so removed items leave the collection for everyone. This cannot be undone.`)) return;
try {
const res = await postJSON(`/api/favorites/collection/${state.selectedCollectionId}/cleanup`, { userId: state.selectedUserId, dryRun: false });
renderActionResult('cleanup', res);
toast(`Removed ${res.summary.removed_count} watched item${res.summary.removed_count === 1 ? '' : 's'}`);
$('#btnApplyCleanup').disabled = true;
await refreshCounts();
await loadView();
} catch (e) { toast(e.message, false); }
}
/* ── regenerate ── */
function targetSize() { return Math.max(1, parseInt($('#targetSize').value, 10) || 25); }
async function previewRegen() {
if (!actionsReady()) return;
try {
const res = await postJSON(`/api/favorites/collection/${state.selectedCollectionId}/regenerate`, { userId: state.selectedUserId, dryRun: true, targetSize: targetSize() });
renderActionResult('regenerate', res);
renderSummary({
current: res.summary.current_count, watched: $('#statWatched').textContent,
recommended: res.summary.recommended_count, final: res.summary.final_count,
});
$('#btnApplyRegen').disabled = res.summary.recommended_count === 0;
toast(res.message ? res.message : `${res.summary.recommended_count} recommendation${res.summary.recommended_count === 1 ? '' : 's'} ready`, !res.message || res.summary.recommended_count > 0);
} catch (e) { toast(e.message, false); }
}
async function applyRegen() {
if (!actionsReady()) return;
const where = (state.view && state.view.collection_name) || 'this collection';
if (!confirm(`Add recommendations to "${where}" (target ${targetSize()} items)? Collections are shared with all users.`)) return;
try {
const res = await postJSON(`/api/favorites/collection/${state.selectedCollectionId}/regenerate`, { userId: state.selectedUserId, dryRun: false, targetSize: targetSize() });
renderActionResult('regenerate', res);
toast(`Added ${res.summary.added_count} item${res.summary.added_count === 1 ? '' : 's'}`);
$('#btnApplyRegen').disabled = true;
await refreshCounts();
await loadView();
} catch (e) { toast(e.message, false); }
}
$('#btnPreviewCleanup').addEventListener('click', previewCleanup);
$('#btnApplyCleanup').addEventListener('click', applyCleanup);
$('#btnPreviewRegen').addEventListener('click', previewRegen);
$('#btnApplyRegen').addEventListener('click', applyRegen);
$('#btnRefresh').addEventListener('click', loadView);
/* ── status ── */
(async () => {
try {
const config = await (await fetch('/api/config')).json();
if (config.connected) { $('#statusText').textContent = 'Connected to Emby'; }
else { $('#statusDot').classList.add('off'); $('#statusText').textContent = 'No API key'; }
} catch {
$('#statusDot').classList.add('off'); $('#statusText').textContent = 'Connection error';
}
})();
loadOverview();
</script>
</body>
</html>