660 lines
31 KiB
HTML
660 lines
31 KiB
HTML
<!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 => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[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>
|