+ {!n?.connected ? (
+ }>
+ {n?.configured
+ ? "Navidrome is configured but offline."
+ : "Navidrome is not configured. Add your server details in Settings."}
+
+ ) : (
+ <>
+
+ }>
+ Compares the albums you own (from a database-backed library scan) against MusicBrainz to surface albums you may
+ be missing. Scanning and metadata lookups run as background jobs — this page only reads the database.
+
+
+ )}
+ >
+ );
+}
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
new file mode 100644
index 0000000..7da1196
--- /dev/null
+++ b/frontend/src/styles.css
@@ -0,0 +1,1197 @@
+@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&display=swap");
+
+/* ============================================================================
+ HomelabToolkit — Tracearr-modeled design system (ported from app-theme.css)
+ ========================================================================== */
+:root {
+ --bg: #0a0c0f;
+ --bg-2: #0c0f13;
+ --surface: #101419;
+ --surface2: #151a21;
+ --surface3: #1b222b;
+ --surface4: #232c37;
+
+ --border: rgba(151, 167, 187, 0.12);
+ --border-strong: rgba(151, 167, 187, 0.22);
+ --border-active: #36d6e0;
+
+ --text: #e8edf3;
+ --text-2: #9aa7b6;
+ --text-3: #5e6b7b;
+
+ --accent: #36d6e0;
+ --accent-h: #5ee7ef;
+ --accent-2: #2bb6c4;
+ --accent-glow: rgba(54, 214, 224, 0.15);
+ --accent-soft: rgba(54, 214, 224, 0.12);
+
+ --green: #46d99a;
+ --green-bg: rgba(70, 217, 154, 0.12);
+ --green-bd: rgba(70, 217, 154, 0.3);
+ --amber: #f3c969;
+ --amber-bg: rgba(243, 201, 105, 0.12);
+ --amber-bd: rgba(243, 201, 105, 0.3);
+ --red: #f0726f;
+ --red-bg: rgba(240, 114, 111, 0.12);
+ --red-bd: rgba(240, 114, 111, 0.3);
+
+ --purple: #b18cff;
+ --purple-bg: rgba(177, 140, 255, 0.12);
+ --purple-bd: rgba(177, 140, 255, 0.3);
+
+ --shadow-soft: 0 14px 38px rgba(2, 5, 10, 0.42);
+ --shadow-strong: 0 26px 60px rgba(2, 5, 10, 0.55);
+ --r: 10px;
+ --r-lg: 14px;
+ --ease-out: cubic-bezier(0.22, 1, 0.36, 1);
+}
+
+* {
+ box-sizing: border-box;
+ scrollbar-width: thin;
+ scrollbar-color: var(--border-strong) transparent;
+}
+*::-webkit-scrollbar {
+ width: 9px;
+ height: 9px;
+}
+*::-webkit-scrollbar-thumb {
+ background: var(--border-strong);
+ border-radius: 99px;
+ border: 2px solid transparent;
+ background-clip: padding-box;
+}
+*::-webkit-scrollbar-thumb:hover {
+ background: var(--text-3);
+ background-clip: padding-box;
+}
+
+html {
+ color-scheme: dark;
+}
+body {
+ margin: 0;
+ font-family: "IBM Plex Sans", system-ui, -apple-system, "Segoe UI", sans-serif;
+ background: var(--bg);
+ color: var(--text);
+ font-size: 15px;
+ -webkit-font-smoothing: antialiased;
+}
+input,
+button,
+select,
+textarea {
+ font-family: inherit;
+}
+a {
+ color: var(--accent-h);
+ text-decoration: none;
+}
+::selection {
+ background: var(--accent-glow);
+ color: var(--text);
+}
+
+/* ── App layout ───────────────────────────────────────────────────────────── */
+.app {
+ display: grid;
+ grid-template-columns: 244px 1fr;
+ min-height: 100vh;
+}
+.main {
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+}
+
+/* ── Sidebar ──────────────────────────────────────────────────────────────── */
+.nav {
+ position: sticky;
+ top: 0;
+ height: 100vh;
+ display: flex;
+ flex-direction: column;
+ background: var(--surface);
+ border-right: 1px solid var(--border);
+}
+.nav-brand {
+ display: flex;
+ align-items: center;
+ gap: 11px;
+ min-height: 64px;
+ padding: 0 16px;
+ border-bottom: 1px solid var(--border);
+}
+.nav-logo {
+ width: 32px;
+ height: 32px;
+ display: grid;
+ place-items: center;
+ border-radius: 9px;
+ font-weight: 800;
+ font-size: 15px;
+ background: linear-gradient(155deg, #5ee7ef 0%, #36d6e0 45%, #1f9aa6 100%);
+ color: #04181b;
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.35), 0 4px 14px rgba(54, 214, 224, 0.28);
+}
+.nav-name {
+ font-size: 17px;
+ font-weight: 700;
+ letter-spacing: -0.02em;
+}
+.nav-name small {
+ display: block;
+ font-size: 11px;
+ font-weight: 500;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ color: var(--text-3);
+}
+.nav-scroll {
+ flex: 1;
+ overflow-y: auto;
+ padding: 12px 10px;
+}
+.nav-group + .nav-group {
+ margin-top: 6px;
+}
+/* Collapsible category header (button) */
+.nav-group-head {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ width: 100%;
+ padding: 9px 12px;
+ border: 0;
+ background: transparent;
+ font-family: inherit;
+ font-size: 12px;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--text-3);
+ cursor: pointer;
+ border-radius: 9px;
+ transition: background 150ms var(--ease-out), color 150ms var(--ease-out);
+}
+.nav-group-head:hover {
+ background: var(--surface2);
+ color: var(--text-2);
+}
+.nav-group-head > svg {
+ width: 15px;
+ height: 15px;
+ opacity: 0.8;
+ flex-shrink: 0;
+}
+.nav-caret {
+ width: 15px !important;
+ height: 15px !important;
+ opacity: 0.7;
+ transition: transform 240ms var(--ease-out);
+}
+.nav-caret.open {
+ transform: rotate(90deg);
+}
+/* Modern height animation via grid-template-rows 0fr → 1fr */
+.nav-group-panel {
+ display: grid;
+ grid-template-rows: 0fr;
+ transition: grid-template-rows 260ms var(--ease-out);
+}
+.nav-group-panel.open {
+ grid-template-rows: 1fr;
+}
+.nav-group-inner {
+ overflow: hidden;
+ min-height: 0;
+ padding: 2px 0;
+ opacity: 0;
+ transform: translateY(-4px);
+ transition: opacity 220ms var(--ease-out), transform 220ms var(--ease-out);
+}
+.nav-group-panel.open .nav-group-inner {
+ opacity: 1;
+ transform: translateY(0);
+}
+.stat-icon svg {
+ width: 19px;
+ height: 19px;
+}
+.nav-item {
+ position: relative;
+ display: flex;
+ align-items: center;
+ gap: 11px;
+ min-height: 40px;
+ padding: 9px 12px 9px 22px;
+ border-radius: 9px;
+ font-size: 15px;
+ font-weight: 500;
+ color: var(--text-2);
+ cursor: pointer;
+ transition: background 160ms var(--ease-out), color 160ms var(--ease-out);
+}
+.nav-item-top {
+ padding-left: 12px;
+}
+.nav-item + .nav-item {
+ margin-top: 2px;
+}
+.nav-item svg {
+ width: 16px;
+ height: 16px;
+ opacity: 0.85;
+ flex-shrink: 0;
+}
+.nav-item:hover {
+ background: var(--surface2);
+ color: var(--text);
+}
+.nav-item.active {
+ background: var(--accent-soft);
+ color: var(--accent-h);
+}
+.nav-item.active svg {
+ opacity: 1;
+}
+.nav-item.active::before {
+ content: "";
+ position: absolute;
+ left: 4px;
+ top: 50%;
+ transform: translateY(-50%);
+ width: 3px;
+ height: 16px;
+ border-radius: 99px;
+ background: var(--accent);
+ box-shadow: 0 0 10px var(--accent-glow);
+}
+.nav-foot {
+ padding: 12px;
+ border-top: 1px solid var(--border);
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+.nav-version {
+ font-size: 11px;
+ color: var(--text-3);
+ letter-spacing: 0.02em;
+ padding-left: 4px;
+ font-variant-numeric: tabular-nums;
+}
+
+/* connection chip */
+.status-chip {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 11px;
+ border-radius: 9px;
+ background: var(--surface2);
+ border: 1px solid var(--border);
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--text-2);
+}
+.status-chip .label {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--green);
+ box-shadow: 0 0 0 3px var(--green-bg), 0 0 8px var(--green);
+ flex-shrink: 0;
+}
+.dot.off {
+ background: var(--red);
+ box-shadow: 0 0 0 3px var(--red-bg), 0 0 8px var(--red);
+}
+.dot.idle {
+ background: var(--text-3);
+ box-shadow: 0 0 0 3px rgba(94, 107, 123, 0.15);
+}
+
+/* ── Topbar / header ──────────────────────────────────────────────────────── */
+.topbar {
+ position: sticky;
+ top: 0;
+ z-index: 20;
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ padding: 0 36px;
+ min-height: 60px;
+ background: var(--bg);
+ border-bottom: 1px solid var(--border);
+}
+.topbar .crumbs {
+ font-size: 12.5px;
+ color: var(--text-3);
+ letter-spacing: 0.02em;
+}
+.topbar .crumbs b {
+ color: var(--text-2);
+ font-weight: 600;
+}
+.topbar-spacer {
+ flex: 1;
+}
+
+.content {
+ padding: 26px 36px 40px;
+ width: 100%;
+}
+
+.page-head {
+ margin-bottom: 22px;
+}
+.page-head-row {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+.page-head-icon {
+ width: 36px;
+ height: 36px;
+ display: grid;
+ place-items: center;
+ border-radius: 10px;
+ background: var(--accent-soft);
+ color: var(--accent-h);
+ flex-shrink: 0;
+}
+.page-head-icon svg {
+ width: 20px;
+ height: 20px;
+}
+.page-head h1 {
+ margin: 0;
+ font-size: 28px;
+ font-weight: 700;
+ letter-spacing: -0.02em;
+}
+.page-head p {
+ margin: 8px 0 0;
+ font-size: 15px;
+ color: var(--text-2);
+ max-width: 72ch;
+ line-height: 1.6;
+}
+
+/* ── Cards / panels ───────────────────────────────────────────────────────── */
+.panel {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--r-lg);
+}
+.panel-head {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 16px 18px;
+ border-bottom: 1px solid var(--border);
+}
+.panel-head h3 {
+ margin: 0;
+ font-size: 17px;
+ font-weight: 600;
+ letter-spacing: -0.01em;
+}
+.panel-head .sub {
+ font-size: 13px;
+ color: var(--text-3);
+}
+.panel-body {
+ padding: 18px;
+}
+
+/* ── Section labels (dashboard groupings) ─────────────────────────────────── */
+.section-label {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin: 4px 0 12px;
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--text-3);
+}
+.section-label svg {
+ width: 14px;
+ height: 14px;
+ opacity: 0.8;
+}
+
+/* ── Dashboard split (Emby | Navidrome) ───────────────────────────────────── */
+.dash-split {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 18px;
+ margin-bottom: 26px;
+}
+@media (max-width: 1080px) {
+ .dash-split {
+ grid-template-columns: 1fr;
+ }
+}
+.mini-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 12px;
+}
+.mini-stat {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 14px 16px;
+ border-radius: var(--r);
+ background: var(--surface2);
+ border: 1px solid var(--border);
+}
+.mini-stat .stat-icon {
+ width: 34px;
+ height: 34px;
+}
+.mini-stat .stat-value {
+ font-size: 19px;
+}
+.mini-stat .stat-label {
+ font-size: 12px;
+}
+
+/* genre breakdown bars */
+.genre-row {
+ display: grid;
+ grid-template-columns: 110px 1fr 52px;
+ align-items: center;
+ gap: 12px;
+ padding: 5px 0;
+}
+.genre-name {
+ font-size: 13px;
+ color: var(--text);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.genre-bar {
+ height: 7px;
+ border-radius: 99px;
+ background: var(--surface3);
+ overflow: hidden;
+}
+.genre-bar-fill {
+ height: 100%;
+ border-radius: 99px;
+ background: linear-gradient(90deg, var(--accent-2), var(--accent));
+}
+.genre-count {
+ font-size: 12px;
+ color: var(--text-3);
+ text-align: right;
+ font-variant-numeric: tabular-nums;
+}
+
+/* format distribution (stacked bar + legend) */
+.stack-bar {
+ display: flex;
+ height: 14px;
+ border-radius: 99px;
+ overflow: hidden;
+ background: var(--surface3);
+}
+.stack-seg {
+ height: 100%;
+ transition: width 240ms var(--ease-out);
+}
+.legend {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 6px 16px;
+ margin-top: 12px;
+}
+.legend-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 12.5px;
+}
+.legend-dot {
+ width: 9px;
+ height: 9px;
+ border-radius: 3px;
+ flex-shrink: 0;
+}
+.legend-name {
+ text-transform: uppercase;
+ font-weight: 600;
+ letter-spacing: 0.03em;
+ color: var(--text);
+}
+.legend-count {
+ margin-left: auto;
+ color: var(--text-3);
+ font-variant-numeric: tabular-nums;
+}
+
+/* ── Completeness rows ────────────────────────────────────────────────────── */
+.completeness-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ width: 100%;
+ padding: 13px 16px;
+ border: 0;
+ background: transparent;
+ color: var(--text);
+ font-family: inherit;
+ font-size: 14px;
+ cursor: pointer;
+ transition: background 140ms var(--ease-out);
+}
+.completeness-row:hover {
+ background: rgba(54, 214, 224, 0.04);
+}
+.completeness-pct {
+ width: 52px;
+ text-align: right;
+ color: var(--text-2);
+ font-weight: 600;
+}
+
+/* ── Tool quick-links ─────────────────────────────────────────────────────── */
+.tool-card {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 14px 16px;
+}
+.tool-card:hover {
+ border-color: var(--border-strong);
+}
+
+/* ── Stat cards ───────────────────────────────────────────────────────────── */
+.stat-row {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 14px;
+}
+.stat-card {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ padding: 18px 20px;
+ border-radius: var(--r-lg);
+ background: var(--surface);
+ border: 1px solid var(--border);
+}
+.stat-icon {
+ width: 40px;
+ height: 40px;
+ border-radius: 11px;
+ display: grid;
+ place-items: center;
+ background: var(--accent-soft);
+ color: var(--accent-h);
+ flex-shrink: 0;
+}
+.stat-meta {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+}
+.stat-value {
+ font-size: 25px;
+ font-weight: 700;
+ letter-spacing: -0.02em;
+ font-variant-numeric: tabular-nums;
+}
+.stat-label {
+ font-size: 13.5px;
+ color: var(--text-2);
+}
+
+/* ── Buttons ──────────────────────────────────────────────────────────────── */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ padding: 9px 14px;
+ border-radius: 10px;
+ border: 1px solid var(--border);
+ background: var(--surface2);
+ color: var(--text);
+ font-size: 14px;
+ font-weight: 600;
+ font-family: inherit;
+ cursor: pointer;
+ transition: background 160ms var(--ease-out), border-color 160ms var(--ease-out), transform 120ms var(--ease-out),
+ opacity 160ms var(--ease-out);
+}
+.btn svg {
+ width: 16px;
+ height: 16px;
+}
+.btn:hover:not(:disabled) {
+ background: var(--surface3);
+ border-color: var(--border-strong);
+}
+.btn:active:not(:disabled) {
+ transform: translateY(1px);
+}
+.btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+.btn-primary {
+ background: var(--accent);
+ border-color: rgba(54, 214, 224, 0.4);
+ color: #04181b;
+}
+.btn-primary:hover:not(:disabled) {
+ background: var(--accent-h);
+ border-color: rgba(94, 231, 239, 0.5);
+}
+.btn-green {
+ background: var(--green-bg);
+ border-color: var(--green-bd);
+ color: #b7f0d4;
+}
+.btn-green:hover:not(:disabled) {
+ background: rgba(70, 217, 154, 0.22);
+}
+.btn-danger {
+ background: var(--red-bg);
+ border-color: var(--red-bd);
+ color: #ffb4b2;
+}
+.btn-danger:hover:not(:disabled) {
+ background: rgba(240, 114, 111, 0.22);
+}
+.btn-sm {
+ padding: 6px 10px;
+ font-size: 12px;
+}
+.btn-block {
+ width: 100%;
+}
+
+/* ── Inputs ───────────────────────────────────────────────────────────────── */
+.input,
+.select,
+.textarea {
+ width: 100%;
+ padding: 9px 12px;
+ border-radius: 10px;
+ border: 1px solid var(--border);
+ background: var(--surface2);
+ color: var(--text);
+ font-size: 13px;
+ font-family: inherit;
+ transition: border-color 160ms var(--ease-out), box-shadow 160ms var(--ease-out);
+}
+.input::placeholder,
+.textarea::placeholder {
+ color: var(--text-3);
+}
+.input:focus,
+.select:focus,
+.textarea:focus {
+ border-color: var(--border-active);
+ box-shadow: 0 0 0 3px var(--accent-glow);
+ outline: none;
+}
+.field {
+ display: flex;
+ flex-direction: column;
+ gap: 7px;
+}
+.field-label {
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--text-3);
+ display: flex;
+ justify-content: space-between;
+}
+input[type="range"] {
+ width: 100%;
+ accent-color: var(--accent);
+}
+input[type="color"] {
+ width: 42px;
+ height: 38px;
+ padding: 2px;
+ border-radius: 10px;
+ border: 1px solid var(--border);
+ background: var(--surface2);
+}
+
+/* ── Segmented control ────────────────────────────────────────────────────── */
+.seg {
+ display: inline-flex;
+ padding: 3px;
+ gap: 2px;
+ border-radius: 10px;
+ background: var(--surface2);
+ border: 1px solid var(--border);
+ flex-wrap: wrap;
+}
+.seg-btn {
+ border: 0;
+ background: transparent;
+ color: var(--text-2);
+ padding: 6px 12px;
+ border-radius: 8px;
+ font-size: 12px;
+ font-weight: 600;
+ cursor: pointer;
+ font-family: inherit;
+ transition: background 150ms var(--ease-out), color 150ms var(--ease-out);
+}
+.seg-btn:hover {
+ color: var(--text);
+}
+.seg-btn.active {
+ background: var(--surface4);
+ color: var(--text);
+ box-shadow: inset 0 0 0 1px var(--border-strong);
+}
+
+/* ── Chips / toggles ──────────────────────────────────────────────────────── */
+.chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 8px 12px;
+ border-radius: 10px;
+ border: 1px solid var(--border);
+ background: var(--surface2);
+ color: var(--text-2);
+ font-size: 12.5px;
+ font-weight: 600;
+ cursor: pointer;
+ font-family: inherit;
+ transition: all 150ms var(--ease-out);
+}
+.chip:hover {
+ border-color: var(--border-strong);
+ color: var(--text);
+}
+.chip.active {
+ background: var(--accent-soft);
+ border-color: var(--border-active);
+ color: var(--text);
+}
+
+/* ── Badges ───────────────────────────────────────────────────────────────── */
+.badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ padding: 2px 9px;
+ border-radius: 999px;
+ font-size: 11px;
+ font-weight: 600;
+ letter-spacing: 0.02em;
+ border: 1px solid var(--border);
+ background: var(--surface3);
+ color: var(--text-2);
+ white-space: nowrap;
+}
+.badge-ok {
+ background: var(--green-bg);
+ border-color: var(--green-bd);
+ color: #b7f0d4;
+}
+.badge-warn {
+ background: var(--amber-bg);
+ border-color: var(--amber-bd);
+ color: #f6d98c;
+}
+.badge-bad {
+ background: var(--red-bg);
+ border-color: var(--red-bd);
+ color: #ffb4b2;
+}
+.badge-accent {
+ background: var(--accent-soft);
+ border-color: rgba(54, 214, 224, 0.3);
+ color: var(--accent-h);
+}
+
+/* ── Tables ───────────────────────────────────────────────────────────────── */
+.data-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 14px;
+}
+.data-table thead th {
+ text-align: left;
+ padding: 11px 14px;
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--text-3);
+ border-bottom: 1px solid var(--border);
+ white-space: nowrap;
+}
+.data-table tbody td {
+ padding: 12px 14px;
+ border-bottom: 1px solid var(--border);
+ color: var(--text-2);
+ vertical-align: middle;
+}
+.data-table tbody tr {
+ transition: background 140ms var(--ease-out);
+}
+.data-table tbody tr:hover {
+ background: rgba(54, 214, 224, 0.04);
+}
+.data-table tbody tr:last-child td {
+ border-bottom: 0;
+}
+.cell-strong {
+ color: var(--text);
+ font-weight: 600;
+}
+.cell-sub {
+ color: var(--text-3);
+ font-size: 12px;
+}
+.avatar {
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ display: grid;
+ place-items: center;
+ font-size: 11px;
+ font-weight: 700;
+ color: #06121a;
+ flex-shrink: 0;
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25);
+}
+
+/* ── Grids ────────────────────────────────────────────────────────────────── */
+.card-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
+ gap: 16px;
+}
+.media-card {
+ border: 1px solid var(--border);
+ border-radius: var(--r-lg);
+ overflow: hidden;
+ background: var(--surface);
+ cursor: pointer;
+ transition: border-color 160ms var(--ease-out), transform 160ms var(--ease-out);
+}
+.media-card:hover {
+ border-color: var(--border-strong);
+ transform: translateY(-2px);
+}
+.media-card.selected {
+ border-color: var(--border-active);
+ box-shadow: 0 0 0 1px var(--accent-glow);
+}
+.media-cover {
+ width: 100%;
+ aspect-ratio: 1 / 1;
+ object-fit: cover;
+ display: block;
+ background: var(--surface3);
+}
+.media-cover.poster {
+ aspect-ratio: 2 / 3;
+}
+.media-body {
+ padding: 11px 12px;
+}
+.media-title {
+ font-size: 15px;
+ font-weight: 600;
+ color: var(--text);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.media-sub {
+ font-size: 13px;
+ color: var(--text-3);
+ margin-top: 2px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+/* ── Result list (sidebar search) ─────────────────────────────────────────── */
+.result-item {
+ display: flex;
+ align-items: center;
+ gap: 11px;
+ padding: 9px 10px;
+ border-radius: 9px;
+ cursor: pointer;
+ border: 1px solid transparent;
+ transition: background 140ms var(--ease-out);
+}
+.result-item:hover {
+ background: var(--surface2);
+}
+.result-item.active {
+ background: var(--accent-soft);
+ border-color: var(--border-active);
+}
+.result-poster {
+ width: 38px;
+ height: 57px;
+ border-radius: 6px;
+ object-fit: cover;
+ background: var(--surface3);
+ flex-shrink: 0;
+}
+.result-name {
+ font-size: 15px;
+ font-weight: 600;
+ color: var(--text);
+}
+.result-sub {
+ font-size: 12px;
+ color: var(--text-3);
+ margin-top: 2px;
+ display: flex;
+ gap: 6px;
+ align-items: center;
+}
+
+/* ── Empty / loading ──────────────────────────────────────────────────────── */
+.empty {
+ padding: 48px 24px;
+ text-align: center;
+ color: var(--text-3);
+ line-height: 1.6;
+}
+.empty svg {
+ width: 38px;
+ height: 38px;
+ margin-bottom: 12px;
+ opacity: 0.5;
+}
+.spinner {
+ width: 18px;
+ height: 18px;
+ border-radius: 50%;
+ border: 2px solid var(--border);
+ border-top-color: var(--accent);
+ animation: spin 0.7s linear infinite;
+ display: inline-block;
+}
+.spinner.lg {
+ width: 30px;
+ height: 30px;
+ border-width: 3px;
+}
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+.center-load {
+ display: grid;
+ place-items: center;
+ padding: 60px;
+}
+
+/* ── Toast ────────────────────────────────────────────────────────────────── */
+.toast-wrap {
+ position: fixed;
+ bottom: 24px;
+ right: 24px;
+ z-index: 100;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ max-width: 380px;
+}
+.toast {
+ padding: 12px 16px;
+ border-radius: 12px;
+ font-size: 13px;
+ font-weight: 500;
+ box-shadow: var(--shadow-soft);
+ border: 1px solid var(--border);
+ background: var(--surface3);
+ color: var(--text);
+ animation: toast-in 240ms var(--ease-out);
+}
+.toast.ok {
+ background: var(--green-bg);
+ border-color: var(--green-bd);
+ color: #b7f0d4;
+}
+.toast.err {
+ background: var(--red-bg);
+ border-color: var(--red-bd);
+ color: #ffb4b2;
+}
+@keyframes toast-in {
+ from {
+ opacity: 0;
+ transform: translateY(8px);
+ }
+}
+
+/* ── Utility ──────────────────────────────────────────────────────────────── */
+.row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+.row.wrap {
+ flex-wrap: wrap;
+}
+.between {
+ justify-content: space-between;
+}
+.col {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+.muted {
+ color: var(--text-2);
+}
+.dim {
+ color: var(--text-3);
+}
+.grow {
+ flex: 1;
+}
+.mono {
+ font-variant-numeric: tabular-nums;
+}
+.gap-sm {
+ gap: 8px;
+}
+.mt {
+ margin-top: 16px;
+}
+.hint {
+ font-size: 12px;
+ color: var(--text-3);
+ line-height: 1.5;
+}
+
+/* ── Search bar ───────────────────────────────────────────────────────────── */
+.search-inner {
+ position: relative;
+}
+.search-inner svg {
+ position: absolute;
+ left: 12px;
+ top: 50%;
+ transform: translateY(-50%);
+ width: 16px;
+ height: 16px;
+ color: var(--text-3);
+}
+.search-inner .input {
+ padding-left: 36px;
+}
+
+/* ── Two-column workbench (generator) ─────────────────────────────────────── */
+.workbench {
+ display: grid;
+ grid-template-columns: 320px 1fr 300px;
+ gap: 18px;
+ align-items: start;
+}
+@media (max-width: 1100px) {
+ .workbench {
+ grid-template-columns: 1fr;
+ }
+}
+.scroll-col {
+ max-height: calc(100vh - 180px);
+ overflow-y: auto;
+}
+
+/* preview frame */
+.preview-shell {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--r-lg);
+ padding: 20px;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ align-items: center;
+}
+.preview-frame {
+ width: 100%;
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: var(--r);
+ overflow: hidden;
+ display: grid;
+ place-items: center;
+ min-height: 220px;
+}
+.preview-frame img {
+ width: 100%;
+ display: block;
+}
+
+/* log console */
+.console {
+ background: #06090d;
+ border: 1px solid var(--border);
+ border-radius: var(--r);
+ padding: 12px 14px;
+ font-family: "SF Mono", "Cascadia Code", Consolas, monospace;
+ font-size: 12px;
+ line-height: 1.7;
+ max-height: 360px;
+ overflow-y: auto;
+}
+.log-line {
+ display: flex;
+ gap: 10px;
+}
+.log-tag {
+ flex-shrink: 0;
+ width: 58px;
+ font-weight: 700;
+ text-transform: uppercase;
+ font-size: 10px;
+ letter-spacing: 0.05em;
+ padding-top: 1px;
+}
+.log-ok {
+ color: var(--green);
+}
+.log-dry {
+ color: var(--accent);
+}
+.log-skip {
+ color: var(--text-3);
+}
+.log-warn {
+ color: var(--amber);
+}
+.log-info {
+ color: var(--text-2);
+}
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 0000000..c1183c9
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "compilerOptions": {
+ "target": "ES2021",
+ "useDefineForClassFields": true,
+ "lib": ["ES2021", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": false,
+ "noUnusedParameters": false,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["src"]
+}
diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo
new file mode 100644
index 0000000..05cf50d
--- /dev/null
+++ b/frontend/tsconfig.tsbuildinfo
@@ -0,0 +1 @@
+{"root":["./src/app.tsx","./src/api.ts","./src/main.tsx","./src/components/sidebar.tsx","./src/components/icons.tsx","./src/components/ui.tsx","./src/lib/toast.tsx","./src/pages/dashboard.tsx","./src/pages/settings.tsx","./src/pages/emby/airing.tsx","./src/pages/emby/bulkassign.tsx","./src/pages/emby/collections.tsx","./src/pages/emby/favorites.tsx","./src/pages/emby/generator.tsx","./src/pages/navidrome/collectioncompleteness.tsx","./src/pages/navidrome/covermanager.tsx","./src/pages/navidrome/library.tsx"],"version":"5.9.3"}
\ No newline at end of file
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
new file mode 100644
index 0000000..bd7b44a
--- /dev/null
+++ b/frontend/vite.config.ts
@@ -0,0 +1,18 @@
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+
+// The FastAPI backend serves the built app from ./dist and owns every /api route.
+// During `npm run dev` we proxy /api (and Emby/Navidrome image routes) to it.
+export default defineConfig({
+ plugins: [react()],
+ build: {
+ outDir: "dist",
+ emptyOutDir: true,
+ },
+ server: {
+ port: 5173,
+ proxy: {
+ "/api": "http://localhost:8500",
+ },
+ },
+});
diff --git a/music-covers.py b/music-covers.py
new file mode 100644
index 0000000..bb42d9e
--- /dev/null
+++ b/music-covers.py
@@ -0,0 +1,777 @@
+from pathlib import Path
+import itertools
+import shutil
+import sys
+import re
+import threading
+import time
+import requests
+import musicbrainzngs
+from mutagen import File, MutagenError
+
+MUSIC_ROOT = Path(r"\\Matt-htpc\d\Music")
+
+DRY_RUN = False # keep True first. Set False only after checking output.
+ENABLE_LYRICS = False
+ENABLE_FOLDER_CLEANUP = True
+ENABLE_RENAME = True
+ENABLE_FILE_CLEANUP = True
+RECENT_FOLDERS_ONLY = False
+RECENT_FOLDER_WINDOW_SECONDS = 2 * 60 * 60
+
+COVER_NAME_PRIORITY = ("cover.jpg", "folder.jpg", "front.jpg")
+COVER_NAMES = set(COVER_NAME_PRIORITY)
+COVER_MISSING_MARKER = ".cover-not-found"
+LYRICS_MISSING_MARKER = ".lyrics-not-found"
+LYRICS_SIDECAR_EXTENSIONS = {".lrc", ".txt"}
+AUDIO_EXTENSIONS = {".mp3", ".flac", ".m4a"}
+YEAR_ALBUM_FOLDER_RE = re.compile(r"^\s*(\d{4})\s*-\s*(.+?)\s*$")
+
+musicbrainzngs.set_useragent(
+ "NavidromeCoverDownloader",
+ "1.0",
+ "your-email@example.com"
+)
+
+
+class UI:
+ RESET = "\033[0m"
+ BOLD = "\033[1m"
+ DIM = "\033[2m"
+ RED = "\033[31m"
+ GREEN = "\033[32m"
+ YELLOW = "\033[33m"
+ BLUE = "\033[34m"
+ MAGENTA = "\033[35m"
+ CYAN = "\033[36m"
+ WHITE = "\033[37m"
+ ORANGE = "\033[38;5;208m"
+ MUTED = "\033[38;5;244m"
+ BG = "\033[48;5;236m"
+
+ enabled = sys.stdout.isatty()
+
+
+def enable_terminal_colors():
+ if not UI.enabled:
+ return
+
+ if sys.platform != "win32":
+ return
+
+ try:
+ import ctypes
+
+ kernel32 = ctypes.windll.kernel32
+ handle = kernel32.GetStdHandle(-11)
+ mode = ctypes.c_uint()
+ if kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
+ kernel32.SetConsoleMode(handle, mode.value | 0x0004)
+ except Exception:
+ UI.enabled = False
+
+
+def style(text: str, *codes: str) -> str:
+ if not UI.enabled:
+ return text
+ return "".join(codes) + text + UI.RESET
+
+
+def line(char: str = "-") -> str:
+ width = shutil.get_terminal_size((88, 20)).columns
+ return style(char * min(width, 88), UI.MUTED)
+
+
+def print_banner():
+ print()
+ print(style("Music Covers", UI.BOLD, UI.ORANGE))
+ print(style("Clean albums, rename tracks, fetch lyrics, and fill missing covers.", UI.DIM))
+ print(line())
+
+
+def info(message: str):
+ print(f"{style('>', UI.CYAN)} {message}")
+
+
+def success(message: str):
+ print(f"{style('OK', UI.GREEN, UI.BOLD)} {message}")
+
+
+def warn(message: str):
+ print(f"{style('WARN', UI.YELLOW, UI.BOLD)} {message}")
+
+
+def skip(message: str):
+ print(f"{style('SKIP', UI.MUTED, UI.BOLD)} {message}")
+
+
+def action(label: str, message: str):
+ print(f"{style(label, UI.ORANGE, UI.BOLD)} {message}")
+
+
+class Spinner:
+ def __init__(self, message: str):
+ self.message = message
+ self.done = threading.Event()
+ self.thread = threading.Thread(target=self._spin, daemon=True)
+
+ def __enter__(self):
+ if UI.enabled:
+ self.thread.start()
+ else:
+ info(self.message)
+ return self
+
+ def __exit__(self, exc_type, exc, tb):
+ if not UI.enabled:
+ return
+
+ self.done.set()
+ self.thread.join()
+ sys.stdout.write("\r" + " " * shutil.get_terminal_size((88, 20)).columns + "\r")
+ sys.stdout.flush()
+
+ def _spin(self):
+ for frame in itertools.cycle("-\\|/"):
+ if self.done.is_set():
+ break
+ sys.stdout.write(f"\r{style(frame, UI.ORANGE)} {style(self.message, UI.DIM)}")
+ sys.stdout.flush()
+ time.sleep(0.08)
+
+
+def get_key() -> str:
+ if sys.platform == "win32":
+ import msvcrt
+
+ key = msvcrt.getch()
+ if key in (b"\x00", b"\xe0"):
+ key = msvcrt.getch()
+ return key.decode(errors="ignore")
+
+ import termios
+ import tty
+
+ fd = sys.stdin.fileno()
+ old = termios.tcgetattr(fd)
+ try:
+ tty.setraw(fd)
+ key = sys.stdin.read(1)
+ if key == "\x1b":
+ key += sys.stdin.read(2)
+ return key
+ finally:
+ termios.tcsetattr(fd, termios.TCSADRAIN, old)
+
+
+def choose_modes():
+ global ENABLE_LYRICS, ENABLE_FOLDER_CLEANUP, ENABLE_RENAME, ENABLE_FILE_CLEANUP, RECENT_FOLDERS_ONLY
+
+ options = [
+ {
+ "label": "Lyric mode",
+ "description": "Fetch missing .lrc or .txt sidecar lyrics",
+ "enabled": ENABLE_LYRICS,
+ },
+ {
+ "label": "Folder cleanup mode",
+ "description": "Normalize album folder names to 'YEAR - Album'",
+ "enabled": ENABLE_FOLDER_CLEANUP,
+ },
+ {
+ "label": "Rename mode",
+ "description": "Rename audio files to 'NN - Title.mp3'",
+ "enabled": ENABLE_RENAME,
+ },
+ {
+ "label": "File cleanup mode",
+ "description": "Remove files except audio, cover art, and lyric sidecars",
+ "enabled": ENABLE_FILE_CLEANUP,
+ },
+ {
+ "label": "Recent folders only",
+ "description": "Process album folders created in the last 2 hours",
+ "enabled": RECENT_FOLDERS_ONLY,
+ },
+ ]
+
+ if not sys.stdin.isatty():
+ return
+
+ if not UI.enabled:
+ print("Select modes. Press Enter to keep defaults, or type numbers to toggle, e.g. 1 3.")
+ for index, option in enumerate(options, 1):
+ marker = "x" if option["enabled"] else " "
+ print(f"[{marker}] {index}. {option['label']} - {option['description']}")
+ answer = input("> ").strip()
+ for token in answer.replace(",", " ").split():
+ if token.isdigit() and 1 <= int(token) <= len(options):
+ options[int(token) - 1]["enabled"] = not options[int(token) - 1]["enabled"]
+ else:
+ selected = 0
+ instructions = "Space toggles Up/Down moves Enter starts"
+
+ while True:
+ sys.stdout.write("\033[?25l")
+ sys.stdout.write("\033[H\033[J")
+ print_banner()
+ print(style("Startup Modes", UI.BOLD, UI.WHITE))
+ print(style(instructions, UI.DIM))
+ print()
+
+ for index, option in enumerate(options):
+ pointer = style(">", UI.ORANGE, UI.BOLD) if index == selected else " "
+ checkbox = style("[x]", UI.GREEN, UI.BOLD) if option["enabled"] else style("[ ]", UI.MUTED)
+ label_color = UI.WHITE if index == selected else UI.RESET
+ print(f"{pointer} {checkbox} {style(option['label'], UI.BOLD, label_color)}")
+ print(f" {style(option['description'], UI.DIM)}")
+
+ key = get_key()
+ if key in ("\r", "\n"):
+ break
+ if key in (" ",):
+ options[selected]["enabled"] = not options[selected]["enabled"]
+ elif key in ("H", "\x1b[A"):
+ selected = (selected - 1) % len(options)
+ elif key in ("P", "\x1b[B"):
+ selected = (selected + 1) % len(options)
+
+ sys.stdout.write("\033[?25h")
+ sys.stdout.write("\033[H\033[J")
+
+ ENABLE_LYRICS = options[0]["enabled"]
+ ENABLE_FOLDER_CLEANUP = options[1]["enabled"]
+ ENABLE_RENAME = options[2]["enabled"]
+ ENABLE_FILE_CLEANUP = options[3]["enabled"]
+ RECENT_FOLDERS_ONLY = options[4]["enabled"]
+
+ print_banner()
+ enabled_modes = ", ".join(option["label"] for option in options if option["enabled"]) or "none"
+ info(f"Enabled modes: {enabled_modes}")
+ print(line())
+
+
+def clean_name(text: str) -> str:
+ text = re.sub(r"\[(.*?)\]|\((.*?)\)", "", text)
+ text = text.replace("_", " ").replace("-", " ")
+ return " ".join(text.split()).strip()
+
+
+def clean_album_folder_name(text: str) -> str:
+ match = YEAR_ALBUM_FOLDER_RE.match(text)
+ if match:
+ text = match.group(2)
+ return clean_name(text)
+
+
+def get_year_from_album_folder_name(text: str) -> str | None:
+ match = YEAR_ALBUM_FOLDER_RE.match(text)
+ if match:
+ return match.group(1)
+ return None
+
+
+def safe_filename(text: str) -> str:
+ text = re.sub(r'[<>:"/\\|?*]', "", text)
+ text = text.strip().rstrip(".")
+ return " ".join(text.split())
+
+
+def clean_track_number(value) -> str | None:
+ if not value:
+ return None
+
+ text = str(value[0] if isinstance(value, list) else value).strip()
+
+ # handles "1/12", "01/12", "1"
+ text = text.split("/")[0].strip()
+
+ if not text.isdigit():
+ return None
+
+ return text.zfill(2)
+
+
+def get_first_tag(audio, names):
+ for name in names:
+ value = audio.get(name)
+ if value:
+ return str(value[0]).strip()
+ return None
+
+
+def load_audio_metadata(path: Path):
+ try:
+ audio = File(path, easy=True)
+ except (MutagenError, OSError) as e:
+ warn(f"could not read metadata ({type(e).__name__}): {path}")
+ return None
+
+ if audio is None:
+ skip(f"unsupported audio metadata: {path}")
+
+ return audio
+
+
+def extract_year(value: str | None) -> str | None:
+ if not value:
+ return None
+
+ match = re.search(r"\b(19\d{2}|20\d{2})\b", str(value))
+ if match:
+ return match.group(1)
+
+ return None
+
+
+def get_album_metadata_from_files(album_folder: Path):
+ for file in album_folder.iterdir():
+ if not file.is_file() or file.suffix.lower() not in AUDIO_EXTENSIONS:
+ continue
+
+ audio = load_audio_metadata(file)
+ if audio is None:
+ continue
+
+ artist = get_first_tag(audio, ["albumartist", "artist"])
+ album = get_first_tag(audio, ["album"])
+ year = extract_year(get_first_tag(audio, ["date", "originaldate", "year"]))
+
+ if artist or album or year:
+ return artist, album, year
+
+ return None, None, None
+
+
+def find_album_year(artist: str, album: str) -> str | None:
+ try:
+ result = musicbrainzngs.search_releases(
+ artist=artist,
+ release=album,
+ limit=5
+ )
+
+ for release in result.get("release-list", []):
+ year = extract_year(release.get("date"))
+ if year:
+ return year
+
+ except Exception as e:
+ warn(f"Error finding album year: {e}")
+
+ return None
+
+
+def is_top_level_music_folder(folder: Path) -> bool:
+ try:
+ return folder.resolve().parent == MUSIC_ROOT.resolve()
+ except OSError:
+ return folder.parent == MUSIC_ROOT
+
+
+def ensure_album_folder_name(album_folder: Path, artist: str, album: str, year: str | None) -> Path:
+ if not album or not year:
+ return album_folder
+
+ if is_top_level_music_folder(album_folder):
+ skip(f"refusing to rename top-level folder as album: {album_folder}")
+ return album_folder
+
+ new_name = f"{year} - {safe_filename(album)}"
+ new_folder = album_folder.with_name(new_name)
+
+ if album_folder.name == new_name:
+ return album_folder
+
+ if new_folder.exists():
+ skip(f"folder rename target exists: {new_folder}")
+ return album_folder
+
+ action("FOLDER", f"{artist}")
+ print(f" {style('From', UI.DIM)} {album_folder.name}")
+ print(f" {style('To', UI.DIM)} {new_name}")
+
+ if DRY_RUN:
+ return album_folder
+
+ album_folder.rename(new_folder)
+ return new_folder
+
+
+def rename_audio_file(path: Path):
+ if path.suffix.lower() not in AUDIO_EXTENSIONS:
+ return path
+
+ audio = load_audio_metadata(path)
+ if audio is None:
+ return path
+
+ artist = get_first_tag(audio, ["artist", "albumartist"])
+ album = get_first_tag(audio, ["album"])
+ title = get_first_tag(audio, ["title"])
+ track = clean_track_number(audio.get("tracknumber"))
+
+ if not artist or not album or not title or not track:
+ skip(f"missing metadata: {path}")
+ return path
+
+ # only rename files already inside Artist\Album structure
+ album_folder = path.parent
+ artist_folder = album_folder.parent
+
+ if not artist_folder.exists() or not album_folder.exists():
+ return path
+
+ new_name = f"{track} - {safe_filename(title)}{path.suffix.lower()}"
+ new_path = path.with_name(new_name)
+
+ if path.name == new_name:
+ return path
+
+ if new_path.exists():
+ skip(f"target exists: {new_path}")
+ return path
+
+ action("RENAME", path.name)
+ print(f" {style('To', UI.DIM)} {new_name}")
+
+ if not DRY_RUN:
+ path.rename(new_path)
+ return new_path
+
+ return path
+
+
+def get_album_cover_to_keep(album_folder: Path) -> str | None:
+ existing = {p.name.lower() for p in album_folder.iterdir() if p.is_file()}
+
+ for name in COVER_NAME_PRIORITY:
+ if name in existing:
+ return name
+
+ return None
+
+
+def should_keep_album_file(path: Path, cover_to_keep: str | None) -> bool:
+ name = path.name.lower()
+ suffix = path.suffix.lower()
+
+ return (
+ suffix in AUDIO_EXTENSIONS
+ or suffix in LYRICS_SIDECAR_EXTENSIONS
+ or name == cover_to_keep
+ )
+
+
+def clean_album_files(album_folder: Path):
+ cover_to_keep = get_album_cover_to_keep(album_folder)
+
+ for file in album_folder.iterdir():
+ if not file.is_file() or should_keep_album_file(file, cover_to_keep):
+ continue
+
+ action("REMOVE", str(file))
+
+ if DRY_RUN:
+ warn(f"DRY RUN: would remove {file}")
+ continue
+
+ try:
+ file.unlink()
+ success(f"Removed {file}")
+ except OSError as e:
+ warn(f"could not remove {file}: {e}")
+
+
+def get_track_metadata(path: Path):
+ audio = load_audio_metadata(path)
+ if audio is None:
+ return None, None, None, None
+
+ artist = get_first_tag(audio, ["artist", "albumartist"])
+ album = get_first_tag(audio, ["album"])
+ title = get_first_tag(audio, ["title"])
+ duration = None
+
+ info = getattr(audio, "info", None)
+ if info and info.length:
+ duration = round(info.length)
+
+ return artist, album, title, duration
+
+
+def has_cover(album_folder: Path) -> bool:
+ existing = {p.name.lower() for p in album_folder.iterdir() if p.is_file()}
+ return any(name in existing for name in COVER_NAMES)
+
+
+def cover_lookup_previously_failed(album_folder: Path, artist: str, album: str) -> bool:
+ marker = album_folder / COVER_MISSING_MARKER
+ if not marker.exists():
+ return False
+
+ try:
+ return marker.read_text(encoding="utf-8").strip() == f"{artist}\n{album}"
+ except OSError:
+ return False
+
+
+def mark_cover_lookup_failed(album_folder: Path, artist: str, album: str):
+ if DRY_RUN:
+ return
+
+ marker = album_folder / COVER_MISSING_MARKER
+ marker.write_text(f"{artist}\n{album}", encoding="utf-8")
+
+
+def lyrics_lookup_key(artist: str, album: str, title: str, duration: int | None) -> str:
+ duration_text = str(duration) if duration else ""
+ return f"{artist}\t{album}\t{title}\t{duration_text}"
+
+
+def get_failed_lyrics_lookups(album_folder: Path) -> set[str]:
+ marker = album_folder / LYRICS_MISSING_MARKER
+ if not marker.exists():
+ return set()
+
+ try:
+ return {
+ line.strip()
+ for line in marker.read_text(encoding="utf-8").splitlines()
+ if line.strip()
+ }
+ except OSError:
+ return set()
+
+
+def mark_lyrics_lookup_failed(album_folder: Path, key: str):
+ if DRY_RUN:
+ return
+
+ marker = album_folder / LYRICS_MISSING_MARKER
+ failed = get_failed_lyrics_lookups(album_folder)
+ failed.add(key)
+ marker.write_text("\n".join(sorted(failed)) + "\n", encoding="utf-8")
+
+
+def has_lyrics(audio_file: Path) -> bool:
+ return any(
+ audio_file.with_suffix(extension).exists()
+ for extension in LYRICS_SIDECAR_EXTENSIONS
+ )
+
+
+def find_album_cover(artist: str, album: str):
+ try:
+ result = musicbrainzngs.search_releases(
+ artist=artist,
+ release=album,
+ limit=3
+ )
+
+ releases = result.get("release-list", [])
+ if not releases:
+ return None
+
+ mbid = releases[0]["id"]
+ url = f"https://coverartarchive.org/release/{mbid}/front-500"
+
+ response = requests.get(url, timeout=20, allow_redirects=True)
+
+ if response.status_code == 200 and response.headers.get("content-type", "").startswith("image"):
+ return response.content
+
+ except Exception as e:
+ warn(f"Error finding cover: {e}")
+
+ return None
+
+
+def find_track_lyrics(artist: str, album: str, title: str, duration: int | None):
+ if not duration:
+ skip("lyrics lookup missing duration")
+ return None
+
+ try:
+ response = requests.get(
+ "https://lrclib.net/api/get",
+ params={
+ "artist_name": artist,
+ "track_name": title,
+ "album_name": album,
+ "duration": duration,
+ },
+ headers={
+ "User-Agent": "NavidromeCoverDownloader/1.0 (local music library script)"
+ },
+ timeout=20,
+ )
+
+ if response.status_code == 404:
+ return None
+
+ if response.status_code != 200:
+ warn(f"Lyrics lookup failed: HTTP {response.status_code}")
+ return None
+
+ data = response.json()
+ synced_lyrics = data.get("syncedLyrics")
+ plain_lyrics = data.get("plainLyrics")
+
+ if synced_lyrics:
+ return ".lrc", synced_lyrics.strip() + "\n"
+
+ if plain_lyrics:
+ return ".txt", plain_lyrics.strip() + "\n"
+
+ except Exception as e:
+ warn(f"Error finding lyrics: {e}")
+
+ return None
+
+
+def download_lyrics_for_track(audio_file: Path, album_artist: str, album_name: str):
+ if has_lyrics(audio_file):
+ return
+
+ track_artist, track_album, title, duration = get_track_metadata(audio_file)
+ artist = track_artist or album_artist
+ album = track_album or album_name
+
+ if not artist or not album or not title:
+ skip(f"lyrics missing metadata: {audio_file}")
+ return
+
+ key = lyrics_lookup_key(artist, album, title, duration)
+ failed = get_failed_lyrics_lookups(audio_file.parent)
+
+ if key in failed:
+ skip(f"lyrics lookup already failed: {artist} - {title}")
+ return
+
+ action("LYRICS", f"{artist} - {title}")
+ with Spinner("Searching LRCLIB"):
+ lyrics = find_track_lyrics(artist, album, title, duration)
+
+ if not lyrics:
+ skip("no lyrics found")
+ mark_lyrics_lookup_failed(audio_file.parent, key)
+ return
+
+ extension, text = lyrics
+ output_file = audio_file.with_suffix(extension)
+
+ if DRY_RUN:
+ warn(f"DRY RUN: would save {output_file}")
+ else:
+ output_file.write_text(text, encoding="utf-8")
+ success(f"Saved {output_file}")
+
+ time.sleep(1)
+
+
+def process_album_folder(album_folder: Path):
+ print()
+ action("ALBUM", str(album_folder))
+
+ tag_artist, tag_album, tag_year = get_album_metadata_from_files(album_folder)
+ artist = tag_artist or clean_name(album_folder.parent.name)
+ album = tag_album or clean_album_folder_name(album_folder.name)
+ year = tag_year or get_year_from_album_folder_name(album_folder.name)
+
+ if ENABLE_FOLDER_CLEANUP and not year and artist and album:
+ with Spinner("Finding album year"):
+ year = find_album_year(artist, album)
+
+ if ENABLE_FOLDER_CLEANUP:
+ album_folder = ensure_album_folder_name(album_folder, artist, album, year)
+
+ audio_files = []
+
+ for file in album_folder.iterdir():
+ if file.is_file() and file.suffix.lower() in AUDIO_EXTENSIONS:
+ if ENABLE_RENAME:
+ audio_files.append(rename_audio_file(file))
+ else:
+ audio_files.append(file)
+
+ if ENABLE_LYRICS:
+ for file in audio_files:
+ download_lyrics_for_track(file, artist, album)
+
+ if has_cover(album_folder):
+ pass
+ elif cover_lookup_previously_failed(album_folder, artist, album):
+ skip(f"cover lookup already failed: {artist} - {album}")
+ else:
+ action("COVER", f"{artist} - {album}")
+
+ with Spinner("Searching Cover Art Archive"):
+ image_data = find_album_cover(artist, album)
+
+ if not image_data:
+ skip("no cover found")
+ mark_cover_lookup_failed(album_folder, artist, album)
+ else:
+ output_file = album_folder / "cover.jpg"
+
+ if DRY_RUN:
+ warn(f"DRY RUN: would save {output_file}")
+ else:
+ output_file.write_bytes(image_data)
+ success(f"Saved {output_file}")
+
+ time.sleep(1)
+
+ if ENABLE_FILE_CLEANUP:
+ clean_album_files(album_folder)
+
+
+def was_created_within_recent_window(folder: Path) -> bool:
+ try:
+ created_at = folder.stat().st_ctime
+ except OSError as e:
+ warn(f"could not read folder timestamps: {folder} ({e})")
+ return False
+
+ age_seconds = time.time() - created_at
+ return 0 <= age_seconds <= RECENT_FOLDER_WINDOW_SECONDS
+
+
+def main():
+ enable_terminal_colors()
+ choose_modes()
+ info(f"MUSIC_ROOT: {MUSIC_ROOT}")
+ info(f"Exists: {MUSIC_ROOT.exists()}")
+ info(f"Is folder: {MUSIC_ROOT.is_dir()}")
+
+ if not MUSIC_ROOT.exists():
+ warn("Music root does not exist.")
+ return
+
+ if any(p.is_file() and p.suffix.lower() in AUDIO_EXTENSIONS for p in MUSIC_ROOT.iterdir()):
+ warn("Music root contains audio files directly; skipping root-level album processing.")
+
+ for first_level_folder in MUSIC_ROOT.iterdir():
+ if not first_level_folder.is_dir():
+ continue
+
+ if any(p.is_file() and p.suffix.lower() in AUDIO_EXTENSIONS for p in first_level_folder.iterdir()):
+ warn(f"Skipping top-level folder with audio files: {first_level_folder}")
+ continue
+
+ # Process only Artist\Album folders. Album folders should never be created
+ # or renamed directly under MUSIC_ROOT.
+ for album_folder in first_level_folder.iterdir():
+ if not album_folder.is_dir():
+ continue
+
+ if RECENT_FOLDERS_ONLY and not was_created_within_recent_window(album_folder):
+ skip(f"outside 2-hour creation window: {album_folder}")
+ continue
+
+ process_album_folder(album_folder)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/requirements.txt b/requirements.txt
index bc906f5..be3b946 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -4,4 +4,6 @@ httpx
Pillow
onnxruntime
python-multipart
-jinja2
\ No newline at end of file
+requests
+mutagen
+musicbrainzngs
diff --git a/services/__init__.py b/services/__init__.py
new file mode 100644
index 0000000..3fc146b
--- /dev/null
+++ b/services/__init__.py
@@ -0,0 +1,8 @@
+"""Service layer for the User Favourites feature.
+
+Every module here depends only on an injected ``client`` object exposing four
+async methods (``get``, ``get_all``, ``post``, ``delete``). Production code passes
+an adapter around the existing Emby helpers in ``app.py``; tests pass a fake. This
+keeps the services free of FastAPI/Emby/Pillow import weight and fully unit
+testable.
+"""
diff --git a/services/db.py b/services/db.py
new file mode 100644
index 0000000..c741ddf
--- /dev/null
+++ b/services/db.py
@@ -0,0 +1,157 @@
+"""SQLite database layer for HomelabToolkit.
+
+Boring and database-first: a single local SQLite file holds the music-library
+scan results, MusicBrainz cache, and collection-completeness data. The schema is
+created idempotently at startup (``init_db``), which doubles as the migration —
+every statement uses ``IF NOT EXISTS``.
+
+Connections are opened per call (cheap for SQLite) so each thread/request gets
+its own handle. WAL mode lets the UI keep reading while a scan job writes.
+"""
+
+from __future__ import annotations
+
+import os
+import sqlite3
+from contextlib import contextmanager
+from datetime import datetime, timezone
+from pathlib import Path
+
+DB_PATH = Path(os.environ.get("DB_PATH", "cache/homelab.db"))
+
+
+def now_iso() -> str:
+ return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
+
+
+@contextmanager
+def connect():
+ DB_PATH.parent.mkdir(parents=True, exist_ok=True)
+ conn = sqlite3.connect(str(DB_PATH), timeout=30)
+ conn.row_factory = sqlite3.Row
+ conn.execute("PRAGMA journal_mode=WAL")
+ conn.execute("PRAGMA synchronous=NORMAL")
+ conn.execute("PRAGMA foreign_keys=ON")
+ conn.execute("PRAGMA busy_timeout=8000")
+ try:
+ yield conn
+ conn.commit()
+ except Exception:
+ conn.rollback()
+ raise
+ finally:
+ conn.close()
+
+
+SCHEMA = """
+CREATE TABLE IF NOT EXISTS library_scan_runs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ kind TEXT NOT NULL, -- 'scan' | 'metadata'
+ status TEXT NOT NULL, -- 'running' | 'completed' | 'failed'
+ started_at TEXT,
+ completed_at TEXT,
+ error_message TEXT,
+ files_scanned INTEGER DEFAULT 0,
+ albums_found INTEGER DEFAULT 0,
+ artists_found INTEGER DEFAULT 0,
+ progress TEXT
+);
+
+CREATE TABLE IF NOT EXISTS library_artists (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ name_normalized TEXT NOT NULL UNIQUE,
+ mbid TEXT,
+ is_various INTEGER DEFAULT 0,
+ is_active INTEGER DEFAULT 1,
+ created_at TEXT,
+ updated_at TEXT
+);
+
+CREATE TABLE IF NOT EXISTS library_albums (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ artist_id INTEGER NOT NULL REFERENCES library_artists(id) ON DELETE CASCADE,
+ title TEXT NOT NULL,
+ title_normalized TEXT NOT NULL,
+ year INTEGER DEFAULT 0,
+ mbid TEXT,
+ is_active INTEGER DEFAULT 1,
+ created_at TEXT,
+ updated_at TEXT,
+ UNIQUE(artist_id, title_normalized, year)
+);
+
+CREATE TABLE IF NOT EXISTS library_tracks (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ album_id INTEGER NOT NULL REFERENCES library_albums(id) ON DELETE CASCADE,
+ title TEXT,
+ track_number INTEGER,
+ disc_number INTEGER,
+ file_path TEXT NOT NULL UNIQUE,
+ file_mtime REAL,
+ file_size INTEGER,
+ mbid TEXT,
+ is_active INTEGER DEFAULT 1,
+ last_seen_scan_id INTEGER,
+ created_at TEXT,
+ updated_at TEXT
+);
+
+CREATE TABLE IF NOT EXISTS external_artist_matches (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ artist_id INTEGER NOT NULL UNIQUE REFERENCES library_artists(id) ON DELETE CASCADE,
+ mb_artist_mbid TEXT,
+ mb_artist_name TEXT,
+ confidence REAL DEFAULT 0,
+ status TEXT, -- 'matched' | 'not_found' | 'error' | 'skipped'
+ checked_at TEXT
+);
+
+CREATE TABLE IF NOT EXISTS external_releases (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ artist_id INTEGER NOT NULL REFERENCES library_artists(id) ON DELETE CASCADE,
+ mb_release_group_mbid TEXT NOT NULL,
+ title TEXT NOT NULL,
+ title_normalized TEXT,
+ first_release_year INTEGER DEFAULT 0,
+ primary_type TEXT,
+ secondary_types TEXT, -- JSON array
+ fetched_at TEXT,
+ UNIQUE(artist_id, mb_release_group_mbid)
+);
+
+CREATE TABLE IF NOT EXISTS collection_completeness (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ artist_id INTEGER NOT NULL REFERENCES library_artists(id) ON DELETE CASCADE,
+ release_group_mbid TEXT NOT NULL,
+ local_album_id INTEGER REFERENCES library_albums(id) ON DELETE SET NULL,
+ title TEXT,
+ year INTEGER DEFAULT 0,
+ status TEXT NOT NULL, -- owned|probably_owned|missing|ignored|uncertain
+ confidence REAL DEFAULT 0,
+ reason TEXT,
+ source TEXT DEFAULT 'musicbrainz',
+ manual_override INTEGER DEFAULT 0,
+ updated_at TEXT,
+ UNIQUE(artist_id, release_group_mbid)
+);
+
+CREATE TABLE IF NOT EXISTS mb_cache (
+ cache_key TEXT PRIMARY KEY,
+ payload TEXT,
+ fetched_at TEXT
+);
+
+CREATE INDEX IF NOT EXISTS idx_albums_artist ON library_albums(artist_id);
+CREATE INDEX IF NOT EXISTS idx_tracks_album ON library_tracks(album_id);
+CREATE INDEX IF NOT EXISTS idx_tracks_path ON library_tracks(file_path);
+CREATE INDEX IF NOT EXISTS idx_releases_artist ON external_releases(artist_id);
+CREATE INDEX IF NOT EXISTS idx_completeness_artist ON collection_completeness(artist_id);
+CREATE INDEX IF NOT EXISTS idx_completeness_status ON collection_completeness(status);
+CREATE INDEX IF NOT EXISTS idx_scan_runs_kind ON library_scan_runs(kind, id);
+"""
+
+
+def init_db() -> None:
+ with connect() as conn:
+ conn.executescript(SCHEMA)
diff --git a/services/emby_collections.py b/services/emby_collections.py
new file mode 100644
index 0000000..d8d8d68
--- /dev/null
+++ b/services/emby_collections.py
@@ -0,0 +1,149 @@
+"""Favourites collection detection and item operations.
+
+A "favourites collection" is any Emby collection (a ``BoxSet``) named
+``"{UserName} Favorites"``. Unlike playlists, collection membership is keyed by
+the item's own id (there is no per-entry id), so additions and removals operate
+on item ids via ``/Collections/{id}/Items``.
+"""
+
+from __future__ import annotations
+
+FAVORITES_SUFFIX = "Favorites"
+
+# Fields requested for every collection/candidate item so the recommendation
+# engine and the UI have what they need in one round trip.
+ITEM_FIELDS = (
+ "Genres,Studios,Tags,People,ProductionYear,RunTimeTicks,"
+ "SeriesName,CommunityRating,MediaType,Overview"
+)
+
+# Emby stores runtime as 100ns ticks. 1 minute = 60 * 1e7 ticks.
+_TICKS_PER_MINUTE = 600_000_000
+
+
+def parse_favorites_owner(collection_name: str | None) -> str | None:
+ """Return the owner name from ``"{Name} Favorites"`` or ``None``.
+
+ Matching is case-insensitive on the suffix but preserves the owner's casing.
+ ``"Favorites"`` on its own (no owner) is not a per-user favourites collection.
+ """
+ if not collection_name:
+ return None
+ name = collection_name.strip()
+ suffix = " " + FAVORITES_SUFFIX
+ if len(name) <= len(suffix):
+ return None
+ if name[-len(suffix):].casefold() != suffix.casefold():
+ return None
+ owner = name[: -len(suffix)].strip()
+ return owner or None
+
+
+def normalize_item(raw: dict) -> dict:
+ """Flatten an Emby item into the shape the feature uses everywhere."""
+ user_data = raw.get("UserData") or {}
+ ticks = raw.get("RunTimeTicks")
+ runtime_minutes = round(ticks / _TICKS_PER_MINUTE) if ticks else None
+ people = raw.get("People") or []
+ return {
+ "id": raw.get("Id", ""),
+ "title": raw.get("Name", ""),
+ "type": raw.get("Type", ""),
+ "media_type": raw.get("MediaType", "") or raw.get("Type", ""),
+ "year": raw.get("ProductionYear"),
+ "runtime_minutes": runtime_minutes,
+ "watched": bool(user_data.get("Played", False)),
+ "community_rating": raw.get("CommunityRating"),
+ "genres": [g for g in (raw.get("Genres") or []) if g],
+ "studios": [s.get("Name") for s in (raw.get("Studios") or []) if s.get("Name")],
+ "tags": [t for t in (raw.get("Tags") or []) if t],
+ "series_name": raw.get("SeriesName"),
+ "directors": [p.get("Name") for p in people if p.get("Type") == "Director" and p.get("Name")],
+ "actors": [p.get("Name") for p in people if p.get("Type") == "Actor" and p.get("Name")],
+ }
+
+
+async def find_all_collections(client) -> list[dict]:
+ """Return every Emby collection (BoxSet), sorted by name.
+
+ ``owner_name`` is the parsed ``"{Name} Favorites"`` owner (or ``None``), and
+ ``is_favorites`` flags whether the collection follows that convention.
+ ``[{"collection_id", "collection_name", "owner_name", "is_favorites", "item_count"}]``
+ """
+ data = await client.get_all(
+ "/Items",
+ {
+ "IncludeItemTypes": "BoxSet",
+ "Recursive": "true",
+ "Fields": "ChildCount",
+ },
+ )
+ collections = []
+ for raw in data:
+ owner = parse_favorites_owner(raw.get("Name"))
+ collections.append(
+ {
+ "collection_id": raw.get("Id", ""),
+ "collection_name": raw.get("Name", ""),
+ "owner_name": owner,
+ "is_favorites": owner is not None,
+ "item_count": raw.get("ChildCount"),
+ }
+ )
+ collections.sort(key=lambda c: (c["collection_name"] or "").casefold())
+ return collections
+
+
+async def find_favorites_collections(client) -> list[dict]:
+ """Return only the detected ``"{Name} Favorites"`` collections."""
+ return [c for c in await find_all_collections(client) if c["is_favorites"]]
+
+
+async def find_user_favorites_collection(client, user_name: str) -> dict | None:
+ """Find the ``"{user_name} Favorites"`` collection, or ``None``."""
+ if not user_name:
+ return None
+ target = user_name.strip().casefold()
+ for collection in await find_favorites_collections(client):
+ if collection["owner_name"].casefold() == target:
+ return collection
+ return None
+
+
+async def list_collection_items(client, collection_id: str, user_id: str) -> list[dict]:
+ """List a collection's items with watched status resolved for ``user_id``.
+
+ Collection children are retrieved via ``ParentId``. Passing the user scope
+ makes Emby populate ``UserData.Played`` for that specific user, which is what
+ makes the watched flag user-specific.
+ """
+ data = await client.get(
+ f"/Users/{user_id}/Items",
+ {
+ "ParentId": collection_id,
+ "Fields": ITEM_FIELDS,
+ "EnableUserData": "true",
+ },
+ )
+ items = data.get("Items", []) if isinstance(data, dict) else (data or [])
+ return [normalize_item(raw) for raw in items]
+
+
+async def add_collection_items(client, collection_id: str, item_ids: list[str]):
+ """Add items to a collection by id. No-op for an empty list."""
+ if not item_ids:
+ return None
+ return await client.post(
+ f"/Collections/{collection_id}/Items",
+ {"Ids": ",".join(item_ids)},
+ )
+
+
+async def remove_collection_items(client, collection_id: str, item_ids: list[str]):
+ """Remove items from a collection by id. No-op when empty."""
+ if not item_ids:
+ return None
+ return await client.delete(
+ f"/Collections/{collection_id}/Items",
+ {"Ids": ",".join(item_ids)},
+ )
diff --git a/services/emby_users.py b/services/emby_users.py
new file mode 100644
index 0000000..eb108f8
--- /dev/null
+++ b/services/emby_users.py
@@ -0,0 +1,39 @@
+"""Emby user lookups."""
+
+from __future__ import annotations
+
+
+def _normalize_user(raw: dict) -> dict:
+ return {"id": raw.get("Id", ""), "name": raw.get("Name", "")}
+
+
+async def fetch_users(client) -> list[dict]:
+ """Return all Emby users as ``[{"id", "name"}]``.
+
+ ``GET /Users`` returns a bare JSON array on most Emby builds, but some return
+ a ``{"Items": [...]}`` envelope. Handle both.
+ """
+ data = await client.get("/Users")
+ raw_users = data.get("Items", []) if isinstance(data, dict) else (data or [])
+ return [_normalize_user(u) for u in raw_users if u.get("Id")]
+
+
+async def resolve_user_id_by_name(client, name: str) -> str | None:
+ """Case-insensitive lookup of a user id by display name."""
+ if not name:
+ return None
+ target = name.strip().casefold()
+ for user in await fetch_users(client):
+ if user["name"].casefold() == target:
+ return user["id"]
+ return None
+
+
+async def get_user(client, user_id: str) -> dict | None:
+ """Return ``{"id", "name"}`` for a user id, or ``None`` if not found."""
+ if not user_id:
+ return None
+ for user in await fetch_users(client):
+ if user["id"] == user_id:
+ return user
+ return None
diff --git a/services/emby_watch_history.py b/services/emby_watch_history.py
new file mode 100644
index 0000000..e767bed
--- /dev/null
+++ b/services/emby_watch_history.py
@@ -0,0 +1,50 @@
+"""Per-user watch history.
+
+Every query here is scoped to a single ``user_id`` via the ``/Users/{id}/Items``
+endpoint, so one user's history is never mixed with another's.
+"""
+
+from __future__ import annotations
+
+from .emby_collections import ITEM_FIELDS, normalize_item
+
+# Movies and series are what we recommend; episodes are folded into their series.
+WATCHED_ITEM_TYPES = "Movie,Series"
+
+
+async def get_watched_items(client, user_id: str, item_types: str = WATCHED_ITEM_TYPES) -> list[dict]:
+ """Return the user's played items (normalized) for the given types."""
+ if not user_id:
+ return []
+ raw_items = await client.get_all(
+ f"/Users/{user_id}/Items",
+ {
+ "Recursive": "true",
+ "IsPlayed": "true",
+ "Filters": "IsPlayed",
+ "IncludeItemTypes": item_types,
+ "Fields": ITEM_FIELDS,
+ "EnableUserData": "true",
+ },
+ )
+ return [normalize_item(raw) for raw in raw_items]
+
+
+async def get_watched_item_ids(client, user_id: str, item_types: str = WATCHED_ITEM_TYPES) -> set[str]:
+ """Return the set of item ids the user has watched."""
+ return {item["id"] for item in await get_watched_items(client, user_id, item_types) if item["id"]}
+
+
+async def is_item_watched(client, user_id: str, item_id: str) -> bool:
+ """Whether ``user_id`` has played ``item_id`` (user-specific)."""
+ if not (user_id and item_id):
+ return False
+ data = await client.get(
+ f"/Users/{user_id}/Items",
+ {"Ids": item_id, "EnableUserData": "true"},
+ )
+ items = data.get("Items", []) if isinstance(data, dict) else (data or [])
+ if not items:
+ return False
+ user_data = items[0].get("UserData") or {}
+ return bool(user_data.get("Played", False))
diff --git a/services/favorites.py b/services/favorites.py
new file mode 100644
index 0000000..8a7a676
--- /dev/null
+++ b/services/favorites.py
@@ -0,0 +1,311 @@
+"""Orchestration for the User Favourites feature.
+
+Combines the user / collection / watch-history / recommendation services into the
+operations the API exposes: list users, browse collections, view a collection's
+items, clean up watched items, and regenerate recommendations. Dry-run is the
+default for both destructive (cleanup) and bulk (regenerate) actions, and the
+destructive actions only run on a user's own ``"{Name} Favorites"`` collection.
+"""
+
+from __future__ import annotations
+
+import logging
+from datetime import datetime, timezone
+from pathlib import Path
+
+from . import emby_collections, emby_users, emby_watch_history, recommendations
+from .recommendations import DEFAULT_TARGET_SIZE
+
+LOG_DIR = Path("logs")
+_logger: logging.Logger | None = None
+
+
+class FavoritesError(Exception):
+ """Raised for expected, user-facing problems (missing user/collection, etc.).
+
+ Carries an HTTP-ish ``status`` so the route layer can map it cleanly.
+ """
+
+ def __init__(self, message: str, status: int = 400):
+ super().__init__(message)
+ self.message = message
+ self.status = status
+
+
+def _get_logger() -> logging.Logger:
+ """Lazily configure a dedicated favourites logger with a file handler.
+
+ Guards against duplicate handlers across uvicorn reloads.
+ """
+ global _logger
+ if _logger is not None:
+ return _logger
+ log = logging.getLogger("homelabtoolkit.favorites")
+ log.setLevel(logging.INFO)
+ if not any(isinstance(h, logging.FileHandler) for h in log.handlers):
+ try:
+ LOG_DIR.mkdir(exist_ok=True)
+ handler = logging.FileHandler(LOG_DIR / "favorites.log", encoding="utf-8")
+ handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s"))
+ log.addHandler(handler)
+ except OSError:
+ # Filesystem unavailable (read-only container): fall back to console.
+ pass
+ _logger = log
+ return log
+
+
+def _now_iso() -> str:
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
+
+
+def _log_action(user_name, collection_name, item, reason, action):
+ record = {
+ "timestamp": _now_iso(),
+ "action": action,
+ "user": user_name,
+ "collection": collection_name,
+ "title": item.get("title", ""),
+ "item_id": item.get("id", ""),
+ "reason": reason,
+ }
+ _get_logger().info(
+ "%s | user=%s | collection=%s | item=%s (%s) | reason=%s",
+ action, user_name, collection_name, record["title"], record["item_id"], reason,
+ )
+ return record
+
+
+async def _resolve_collection(client, collection_id: str, user_id: str) -> tuple[dict, dict]:
+ """Resolve ``(user, collection)`` by id or raise :class:`FavoritesError`."""
+ user = await emby_users.get_user(client, user_id)
+ if not user:
+ raise FavoritesError(f"No Emby user found for id {user_id!r}.", status=404)
+ collection = next(
+ (c for c in await emby_collections.find_all_collections(client) if c["collection_id"] == collection_id),
+ None,
+ )
+ if not collection:
+ raise FavoritesError(f"No collection found for id {collection_id!r}.", status=404)
+ return user, collection
+
+
+async def list_favorites_users(client) -> list[dict]:
+ """Users that have a detected ``"{Name} Favorites"`` collection."""
+ users = await emby_users.fetch_users(client)
+ by_name = {u["name"].casefold(): u for u in users}
+ result = []
+ for collection in await emby_collections.find_favorites_collections(client):
+ user = by_name.get(collection["owner_name"].casefold())
+ if not user:
+ continue # orphan collection with no matching user
+ result.append(
+ {
+ "user_id": user["id"],
+ "user_name": user["name"],
+ "collection_id": collection["collection_id"],
+ "collection_name": collection["collection_name"],
+ "item_count": collection.get("item_count"),
+ }
+ )
+ result.sort(key=lambda r: r["user_name"].casefold())
+ return result
+
+
+async def list_collections_overview(client) -> dict:
+ """All collections plus all users, for the browse pickers.
+
+ Each collection is annotated with ``owner_user_id`` when its ``"{Name}
+ Favorites"`` owner resolves to a real Emby user.
+ """
+ users = await emby_users.fetch_users(client)
+ by_name = {u["name"].casefold(): u for u in users}
+ collections = []
+ for collection in await emby_collections.find_all_collections(client):
+ owner = by_name.get(collection["owner_name"].casefold()) if collection["owner_name"] else None
+ collections.append({**collection, "owner_user_id": owner["id"] if owner else None})
+ return {"collections": collections, "users": users}
+
+
+async def get_collection_items_view(client, collection_id: str, user_id: str) -> dict:
+ """View any collection's items with watched status resolved for ``user_id``.
+
+ Watched status is user-specific. ``actions_enabled`` is true only when the
+ collection is a ``"{Name} Favorites"`` collection and the selected user is its
+ owner, so cleanup/regenerate never touch shared or themed collections.
+ """
+ user = await emby_users.get_user(client, user_id)
+ if not user:
+ raise FavoritesError(f"No Emby user found for id {user_id!r}.", status=404)
+
+ collection = next(
+ (c for c in await emby_collections.find_all_collections(client) if c["collection_id"] == collection_id),
+ None,
+ )
+ if not collection:
+ raise FavoritesError(f"No collection found for id {collection_id!r}.", status=404)
+
+ items = await emby_collections.list_collection_items(client, collection_id, user_id)
+ watched_count = sum(1 for i in items if i["watched"])
+
+ # Cleanup/regenerate are available for any collection. They act on the
+ # selected user's watch data; removal affects the shared collection itself.
+ actions_enabled = True
+ actions_reason = ""
+
+ return {
+ "collection_id": collection_id,
+ "collection_name": collection["collection_name"],
+ "is_favorites": collection["is_favorites"],
+ "owner_name": collection["owner_name"],
+ "user_id": user["id"],
+ "user_name": user["name"],
+ "actions_enabled": actions_enabled,
+ "actions_reason": actions_reason,
+ "items": items,
+ "summary": {
+ "current_count": len(items),
+ "watched_count": watched_count,
+ "unwatched_count": len(items) - watched_count,
+ },
+ }
+
+
+async def cleanup_watched(client, collection_id: str, user_id: str, dry_run: bool = True) -> dict:
+ """Preview or remove items the selected user has already watched.
+
+ Which items are "watched" is resolved per user (via ``UserData.Played`` for
+ this user only). The removal itself operates on the collection, which is
+ shared, so removed items leave the collection for everyone.
+ """
+ user, collection = await _resolve_collection(client, collection_id, user_id)
+ items = await emby_collections.list_collection_items(client, collection_id, user_id)
+
+ watched = [i for i in items if i["watched"]]
+
+ records = []
+ applied = False
+ if not dry_run and watched:
+ await emby_collections.remove_collection_items(
+ client, collection["collection_id"], [i["id"] for i in watched]
+ )
+ applied = True
+ records = [
+ _log_action(user["name"], collection["collection_name"], i, "watched-by-user", "removed")
+ for i in watched
+ ]
+
+ final_count = len(items) - (len(watched) if applied else 0)
+ return {
+ "dry_run": dry_run,
+ "applied": applied,
+ "user_id": user["id"],
+ "user_name": user["name"],
+ "collection_name": collection["collection_name"],
+ "watched_found": len(watched),
+ "removed": [
+ {"title": i["title"], "item_id": i["id"], "reason": "watched-by-user"}
+ for i in watched
+ ],
+ "log": records,
+ "summary": {
+ "current_count": len(items),
+ "watched_count": len(watched),
+ "removed_count": len(watched) if applied else 0,
+ "final_count": final_count,
+ },
+ }
+
+
+async def regenerate(
+ client,
+ collection_id: str,
+ user_id: str,
+ dry_run: bool = True,
+ target_size: int = DEFAULT_TARGET_SIZE,
+) -> dict:
+ """Preview or add recommendations derived from the selected user's history.
+
+ Candidates exclude items the user has watched and items already in the
+ collection, and are never watched items. New items are added until the
+ collection reaches ``target_size``.
+ """
+ if target_size < 0:
+ raise FavoritesError("targetSize must be zero or greater.", status=400)
+
+ user, collection = await _resolve_collection(client, collection_id, user_id)
+ items = await emby_collections.list_collection_items(client, collection_id, user_id)
+ current_ids = {i["id"] for i in items if i["id"]}
+
+ watched_items = await emby_watch_history.get_watched_items(client, user_id)
+ watched_ids = {i["id"] for i in watched_items if i["id"]}
+ profile = recommendations.build_profile(watched_items)
+
+ summary_base = {
+ "current_count": len(items),
+ "watched_history_count": len(watched_items),
+ "target_size": target_size,
+ }
+
+ if profile.is_empty:
+ return {
+ "dry_run": dry_run,
+ "applied": False,
+ "user_id": user["id"],
+ "user_name": user["name"],
+ "collection_name": collection["collection_name"],
+ "message": "No watch history for this user yet, so no recommendations can be made.",
+ "recommended": [],
+ "log": [],
+ "summary": {**summary_base, "recommended_count": 0, "added_count": 0, "final_count": len(items)},
+ }
+
+ exclude_ids = current_ids | watched_ids
+ candidates = await recommendations.build_candidates(client, user_id, profile, exclude_ids)
+ ranked = recommendations.rank_candidates(candidates, profile)
+
+ need = max(0, target_size - len(items))
+ chosen = ranked[:need]
+
+ records = []
+ applied = False
+ if not dry_run and chosen:
+ await emby_collections.add_collection_items(
+ client, collection["collection_id"], [c["id"] for c in chosen]
+ )
+ applied = True
+ records = [
+ _log_action(
+ user["name"], collection["collection_name"], c,
+ f"recommended (score={c['score']})", "added",
+ )
+ for c in chosen
+ ]
+
+ final_count = len(items) + (len(chosen) if applied else 0)
+ return {
+ "dry_run": dry_run,
+ "applied": applied,
+ "user_id": user["id"],
+ "user_name": user["name"],
+ "collection_name": collection["collection_name"],
+ "recommended": [
+ {
+ "title": c["title"],
+ "item_id": c["id"],
+ "type": c["type"],
+ "year": c["year"],
+ "runtime_minutes": c["runtime_minutes"],
+ "community_rating": c["community_rating"],
+ "score": c["score"],
+ }
+ for c in chosen
+ ],
+ "log": records,
+ "summary": {
+ **summary_base,
+ "recommended_count": len(chosen),
+ "added_count": len(chosen) if applied else 0,
+ "final_count": final_count,
+ },
+ }
diff --git a/services/music_covers.py b/services/music_covers.py
new file mode 100644
index 0000000..2b048e3
--- /dev/null
+++ b/services/music_covers.py
@@ -0,0 +1,533 @@
+"""Music library maintenance, refactored from the original ``music-covers.py`` CLI.
+
+The interactive terminal UI is gone; what remains is pure, importable logic the
+web app drives. Two entry points matter:
+
+* :func:`scan_library` — read-only analysis. Walks ``MUSIC_ROOT`` and reports, per
+ album, what each maintenance mode *would* do. Safe to call any time.
+* :func:`process_library` — performs the work. Honours ``dry_run`` (the web UI
+ default) so nothing is renamed, deleted, or downloaded unless the caller opts in.
+
+Both are synchronous (filesystem + blocking HTTP); call them from FastAPI via
+``asyncio.to_thread``. Progress is reported through an optional ``log`` callback.
+"""
+
+from __future__ import annotations
+
+import os
+import re
+import time
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Callable
+
+import requests
+
+try: # Optional: only needed for the "find missing year/cover" online lookups.
+ import musicbrainzngs
+
+ musicbrainzngs.set_useragent("HomelabToolkit", "1.0", "homelab-toolkit@example.com")
+ _HAS_MUSICBRAINZ = True
+except Exception: # pragma: no cover - optional dependency
+ _HAS_MUSICBRAINZ = False
+
+from mutagen import File as MutagenFile, MutagenError
+
+MUSIC_ROOT = Path(os.environ.get("MUSIC_ROOT", r"\\Matt-htpc\d\Music"))
+
+COVER_NAME_PRIORITY = ("cover.jpg", "folder.jpg", "front.jpg")
+COVER_NAMES = set(COVER_NAME_PRIORITY)
+COVER_MISSING_MARKER = ".cover-not-found"
+LYRICS_MISSING_MARKER = ".lyrics-not-found"
+LYRICS_SIDECAR_EXTENSIONS = {".lrc", ".txt"}
+AUDIO_EXTENSIONS = {".mp3", ".flac", ".m4a"}
+YEAR_ALBUM_FOLDER_RE = re.compile(r"^\s*(\d{4})\s*-\s*(.+?)\s*$")
+
+LogCallback = Callable[[dict], None]
+
+
+@dataclass
+class ProcessOptions:
+ folder_cleanup: bool = False
+ rename: bool = False
+ file_cleanup: bool = False
+ lyrics: bool = False
+ covers: bool = True
+ dry_run: bool = True
+
+ @classmethod
+ def from_dict(cls, data: dict) -> "ProcessOptions":
+ return cls(
+ folder_cleanup=bool(data.get("folder_cleanup", False)),
+ rename=bool(data.get("rename", False)),
+ file_cleanup=bool(data.get("file_cleanup", False)),
+ lyrics=bool(data.get("lyrics", False)),
+ covers=bool(data.get("covers", True)),
+ dry_run=bool(data.get("dry_run", True)),
+ )
+
+
+@dataclass
+class _Recorder:
+ """Collects structured action records and forwards them to an optional sink."""
+
+ sink: LogCallback | None = None
+ actions: list[dict] = field(default_factory=list)
+ counts: dict[str, int] = field(default_factory=dict)
+
+ def emit(self, level: str, action: str, message: str, **extra) -> None:
+ record = {"level": level, "action": action, "message": message, **extra}
+ self.actions.append(record)
+ self.counts[action] = self.counts.get(action, 0) + 1
+ if self.sink:
+ self.sink(record)
+
+
+# ── pure string helpers ──────────────────────────────────────────────────────
+
+
+def clean_name(text: str) -> str:
+ text = re.sub(r"\[(.*?)\]|\((.*?)\)", "", text)
+ text = text.replace("_", " ").replace("-", " ")
+ return " ".join(text.split()).strip()
+
+
+def clean_album_folder_name(text: str) -> str:
+ match = YEAR_ALBUM_FOLDER_RE.match(text)
+ if match:
+ text = match.group(2)
+ return clean_name(text)
+
+
+def get_year_from_album_folder_name(text: str) -> str | None:
+ match = YEAR_ALBUM_FOLDER_RE.match(text)
+ return match.group(1) if match else None
+
+
+def safe_filename(text: str) -> str:
+ text = re.sub(r'[<>:"/\\|?*]', "", text)
+ text = text.strip().rstrip(".")
+ return " ".join(text.split())
+
+
+def clean_track_number(value) -> str | None:
+ if not value:
+ return None
+ text = str(value[0] if isinstance(value, list) else value).strip()
+ text = text.split("/")[0].strip()
+ return text.zfill(2) if text.isdigit() else None
+
+
+def extract_year(value: str | None) -> str | None:
+ if not value:
+ return None
+ match = re.search(r"\b(19\d{2}|20\d{2})\b", str(value))
+ return match.group(1) if match else None
+
+
+# ── metadata reading ─────────────────────────────────────────────────────────
+
+
+def _load_audio(path: Path):
+ try:
+ return MutagenFile(path, easy=True)
+ except (MutagenError, OSError):
+ return None
+
+
+def _first_tag(audio, names):
+ for name in names:
+ value = audio.get(name)
+ if value:
+ return str(value[0]).strip()
+ return None
+
+
+def get_album_metadata_from_files(album_folder: Path):
+ for file in album_folder.iterdir():
+ if not file.is_file() or file.suffix.lower() not in AUDIO_EXTENSIONS:
+ continue
+ audio = _load_audio(file)
+ if audio is None:
+ continue
+ artist = _first_tag(audio, ["albumartist", "artist"])
+ album = _first_tag(audio, ["album"])
+ year = extract_year(_first_tag(audio, ["date", "originaldate", "year"]))
+ if artist or album or year:
+ return artist, album, year
+ return None, None, None
+
+
+def get_track_metadata(path: Path):
+ audio = _load_audio(path)
+ if audio is None:
+ return None, None, None, None
+ artist = _first_tag(audio, ["artist", "albumartist"])
+ album = _first_tag(audio, ["album"])
+ title = _first_tag(audio, ["title"])
+ duration = None
+ info = getattr(audio, "info", None)
+ if info and getattr(info, "length", None):
+ duration = round(info.length)
+ return artist, album, title, duration
+
+
+# ── album inspection ─────────────────────────────────────────────────────────
+
+
+def _cover_to_keep(album_folder: Path) -> str | None:
+ existing = {p.name.lower() for p in album_folder.iterdir() if p.is_file()}
+ for name in COVER_NAME_PRIORITY:
+ if name in existing:
+ return name
+ return None
+
+
+def _has_cover(album_folder: Path) -> bool:
+ existing = {p.name.lower() for p in album_folder.iterdir() if p.is_file()}
+ return any(name in existing for name in COVER_NAMES)
+
+
+def _should_keep_file(path: Path, cover_to_keep: str | None) -> bool:
+ name = path.name.lower()
+ suffix = path.suffix.lower()
+ return (
+ suffix in AUDIO_EXTENSIONS
+ or suffix in LYRICS_SIDECAR_EXTENSIONS
+ or name == cover_to_keep
+ )
+
+
+def _has_lyrics(audio_file: Path) -> bool:
+ return any(
+ audio_file.with_suffix(extension).exists()
+ for extension in LYRICS_SIDECAR_EXTENSIONS
+ )
+
+
+def analyze_album(album_folder: Path) -> dict:
+ """Read-only summary of an album folder and the pending maintenance work."""
+ tag_artist, tag_album, tag_year = get_album_metadata_from_files(album_folder)
+ artist = tag_artist or clean_name(album_folder.parent.name)
+ album = tag_album or clean_album_folder_name(album_folder.name)
+ year = tag_year or get_year_from_album_folder_name(album_folder.name)
+
+ audio_files = [
+ f for f in album_folder.iterdir()
+ if f.is_file() and f.suffix.lower() in AUDIO_EXTENSIONS
+ ]
+ cover_to_keep = _cover_to_keep(album_folder)
+ extra_files = [
+ f.name for f in album_folder.iterdir()
+ if f.is_file() and not _should_keep_file(f, cover_to_keep)
+ ]
+
+ suggested_folder = None
+ if album and year:
+ candidate = f"{year} - {safe_filename(album)}"
+ if candidate != album_folder.name:
+ suggested_folder = candidate
+
+ missing_lyrics = sum(1 for f in audio_files if not _has_lyrics(f))
+
+ return {
+ "path": str(album_folder),
+ "folder_name": album_folder.name,
+ "artist": artist or "",
+ "album": album or "",
+ "year": year,
+ "track_count": len(audio_files),
+ "has_cover": _has_cover(album_folder),
+ "suggested_folder": suggested_folder,
+ "needs_folder_rename": suggested_folder is not None,
+ "extra_files": extra_files,
+ "extra_file_count": len(extra_files),
+ "missing_lyrics_count": missing_lyrics,
+ }
+
+
+def _iter_album_folders(root: Path):
+ for first_level in root.iterdir():
+ if not first_level.is_dir():
+ continue
+ try:
+ has_audio = any(
+ p.is_file() and p.suffix.lower() in AUDIO_EXTENSIONS
+ for p in first_level.iterdir()
+ )
+ except OSError:
+ continue
+ if has_audio:
+ # Top-level folder holding audio directly is not an Artist/Album tree.
+ continue
+ for album_folder in first_level.iterdir():
+ if album_folder.is_dir():
+ yield album_folder
+
+
+def scan_library(root: Path | None = None) -> dict:
+ root = root or MUSIC_ROOT
+ if not root.exists():
+ return {"root": str(root), "exists": False, "albums": []}
+
+ albums = []
+ for album_folder in _iter_album_folders(root):
+ try:
+ albums.append(analyze_album(album_folder))
+ except OSError:
+ continue
+ albums.sort(key=lambda a: (a["artist"].lower(), a["year"] or "", a["album"].lower()))
+
+ return {
+ "root": str(root),
+ "exists": True,
+ "album_count": len(albums),
+ "missing_cover_count": sum(1 for a in albums if not a["has_cover"]),
+ "needs_rename_count": sum(1 for a in albums if a["needs_folder_rename"]),
+ "extra_file_count": sum(a["extra_file_count"] for a in albums),
+ "albums": albums,
+ }
+
+
+# ── online lookups (year / cover / lyrics) ───────────────────────────────────
+
+
+def find_album_year(artist: str, album: str) -> str | None:
+ if not _HAS_MUSICBRAINZ:
+ return None
+ try:
+ result = musicbrainzngs.search_releases(artist=artist, release=album, limit=5)
+ for release in result.get("release-list", []):
+ year = extract_year(release.get("date"))
+ if year:
+ return year
+ except Exception:
+ return None
+ return None
+
+
+def find_album_cover(artist: str, album: str) -> bytes | None:
+ if not _HAS_MUSICBRAINZ:
+ return None
+ try:
+ result = musicbrainzngs.search_releases(artist=artist, release=album, limit=3)
+ releases = result.get("release-list", [])
+ if not releases:
+ return None
+ mbid = releases[0]["id"]
+ url = f"https://coverartarchive.org/release/{mbid}/front-500"
+ response = requests.get(url, timeout=20, allow_redirects=True)
+ if response.status_code == 200 and response.headers.get("content-type", "").startswith("image"):
+ return response.content
+ except Exception:
+ return None
+ return None
+
+
+def find_track_lyrics(artist: str, album: str, title: str, duration: int | None):
+ if not duration:
+ return None
+ try:
+ response = requests.get(
+ "https://lrclib.net/api/get",
+ params={
+ "artist_name": artist,
+ "track_name": title,
+ "album_name": album,
+ "duration": duration,
+ },
+ headers={"User-Agent": "HomelabToolkit/1.0 (local music library tool)"},
+ timeout=20,
+ )
+ if response.status_code != 200:
+ return None
+ data = response.json()
+ if data.get("syncedLyrics"):
+ return ".lrc", data["syncedLyrics"].strip() + "\n"
+ if data.get("plainLyrics"):
+ return ".txt", data["plainLyrics"].strip() + "\n"
+ except Exception:
+ return None
+ return None
+
+
+# ── mutating operations (respect dry_run) ────────────────────────────────────
+
+
+def _is_top_level(folder: Path, root: Path) -> bool:
+ try:
+ return folder.resolve().parent == root.resolve()
+ except OSError:
+ return folder.parent == root
+
+
+def _rename_album_folder(album_folder, artist, album, year, root, opts, rec) -> Path:
+ if not album or not year or _is_top_level(album_folder, root):
+ return album_folder
+ new_name = f"{year} - {safe_filename(album)}"
+ if album_folder.name == new_name:
+ return album_folder
+ new_folder = album_folder.with_name(new_name)
+ if new_folder.exists():
+ rec.emit("skip", "folder", f"Rename target already exists: {new_name}")
+ return album_folder
+ rec.emit(
+ "dry" if opts.dry_run else "ok",
+ "folder",
+ f"{album_folder.name} → {new_name}",
+ path=str(album_folder),
+ )
+ if opts.dry_run:
+ return album_folder
+ album_folder.rename(new_folder)
+ return new_folder
+
+
+def _rename_track(path: Path, opts, rec) -> Path:
+ audio = _load_audio(path)
+ if audio is None:
+ return path
+ artist = _first_tag(audio, ["artist", "albumartist"])
+ album = _first_tag(audio, ["album"])
+ title = _first_tag(audio, ["title"])
+ track = clean_track_number(audio.get("tracknumber"))
+ if not (artist and album and title and track):
+ return path
+ new_name = f"{track} - {safe_filename(title)}{path.suffix.lower()}"
+ if path.name == new_name:
+ return path
+ new_path = path.with_name(new_name)
+ if new_path.exists():
+ rec.emit("skip", "rename", f"Target exists: {new_name}")
+ return path
+ rec.emit("dry" if opts.dry_run else "ok", "rename", f"{path.name} → {new_name}")
+ if opts.dry_run:
+ return path
+ path.rename(new_path)
+ return new_path
+
+
+def _clean_files(album_folder: Path, opts, rec) -> None:
+ cover_to_keep = _cover_to_keep(album_folder)
+ for file in album_folder.iterdir():
+ if not file.is_file() or _should_keep_file(file, cover_to_keep):
+ continue
+ rec.emit("dry" if opts.dry_run else "ok", "remove", f"Remove {file.name}", path=str(file))
+ if opts.dry_run:
+ continue
+ try:
+ file.unlink()
+ except OSError as exc:
+ rec.emit("warn", "remove", f"Could not remove {file.name}: {exc}")
+
+
+def _fetch_cover(album_folder, artist, album, opts, rec) -> None:
+ if _has_cover(album_folder):
+ return
+ if not (artist and album):
+ return
+ rec.emit("info", "cover", f"Looking up cover: {artist} - {album}")
+ image = find_album_cover(artist, album)
+ if not image:
+ rec.emit("skip", "cover", f"No cover found: {artist} - {album}")
+ return
+ output = album_folder / "cover.jpg"
+ rec.emit("dry" if opts.dry_run else "ok", "cover", f"Save cover.jpg for {album}")
+ if not opts.dry_run:
+ output.write_bytes(image)
+ time.sleep(1)
+
+
+def _fetch_lyrics(audio_file, album_artist, album_name, opts, rec) -> None:
+ if _has_lyrics(audio_file):
+ return
+ t_artist, t_album, title, duration = get_track_metadata(audio_file)
+ artist = t_artist or album_artist
+ album = t_album or album_name
+ if not (artist and album and title):
+ return
+ lyrics = find_track_lyrics(artist, album, title, duration)
+ if not lyrics:
+ rec.emit("skip", "lyrics", f"No lyrics: {artist} - {title}")
+ return
+ extension, text = lyrics
+ output = audio_file.with_suffix(extension)
+ rec.emit("dry" if opts.dry_run else "ok", "lyrics", f"Save lyrics for {title}")
+ if not opts.dry_run:
+ output.write_text(text, encoding="utf-8")
+ time.sleep(1)
+
+
+def _process_album(album_folder: Path, root: Path, opts: ProcessOptions, rec: _Recorder) -> None:
+ tag_artist, tag_album, tag_year = get_album_metadata_from_files(album_folder)
+ artist = tag_artist or clean_name(album_folder.parent.name)
+ album = tag_album or clean_album_folder_name(album_folder.name)
+ year = tag_year or get_year_from_album_folder_name(album_folder.name)
+
+ if opts.folder_cleanup and not year and artist and album:
+ year = find_album_year(artist, album)
+ if opts.folder_cleanup:
+ album_folder = _rename_album_folder(album_folder, artist, album, year, root, opts, rec)
+
+ audio_files = [
+ f for f in album_folder.iterdir()
+ if f.is_file() and f.suffix.lower() in AUDIO_EXTENSIONS
+ ]
+ if opts.rename:
+ audio_files = [_rename_track(f, opts, rec) for f in audio_files]
+
+ if opts.lyrics:
+ for file in audio_files:
+ _fetch_lyrics(file, artist, album, opts, rec)
+
+ if opts.covers:
+ _fetch_cover(album_folder, artist, album, opts, rec)
+
+ if opts.file_cleanup:
+ _clean_files(album_folder, opts, rec)
+
+
+def process_library(
+ options: ProcessOptions,
+ *,
+ root: Path | None = None,
+ log: LogCallback | None = None,
+ album_paths: list[str] | None = None,
+) -> dict:
+ """Run the selected maintenance modes. Honours ``options.dry_run``.
+
+ ``album_paths`` optionally limits the run to specific album folders.
+ """
+ root = root or MUSIC_ROOT
+ rec = _Recorder(sink=log)
+
+ if not root.exists():
+ rec.emit("warn", "root", f"Music root does not exist: {root}")
+ return {"root": str(root), "dry_run": options.dry_run, "actions": rec.actions, "counts": rec.counts}
+
+ if album_paths:
+ wanted = {str(Path(p)) for p in album_paths}
+ folders = [Path(p) for p in album_paths] if all(Path(p).exists() for p in album_paths) else [
+ f for f in _iter_album_folders(root) if str(f) in wanted
+ ]
+ else:
+ folders = list(_iter_album_folders(root))
+
+ rec.emit(
+ "info",
+ "start",
+ f"{'Dry run' if options.dry_run else 'Applying'} across {len(folders)} album(s)",
+ )
+ for album_folder in folders:
+ try:
+ _process_album(album_folder, root, options, rec)
+ except OSError as exc:
+ rec.emit("warn", "album", f"Error processing {album_folder.name}: {exc}")
+
+ rec.emit("info", "done", "Finished")
+ return {
+ "root": str(root),
+ "dry_run": options.dry_run,
+ "actions": rec.actions,
+ "counts": rec.counts,
+ }
diff --git a/services/music_library.py b/services/music_library.py
new file mode 100644
index 0000000..1a5773f
--- /dev/null
+++ b/services/music_library.py
@@ -0,0 +1,625 @@
+"""Music-library scanning, MusicBrainz enrichment, and completeness logic.
+
+Design rules (per the feature spec):
+* Disk is only walked by an explicit background job, never on page render.
+* The UI reads exclusively from the database.
+* Jobs write progress to ``library_scan_runs`` so the UI can poll.
+* Manual user decisions (ignore / mark owned / mark missing) are never
+ overwritten by a metadata refresh.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import os
+import threading
+from pathlib import Path
+
+from . import db, musicbrainz
+from .music_covers import AUDIO_EXTENSIONS, MUSIC_ROOT, _first_tag, _load_audio, clean_name, clean_track_number, extract_year
+from .text_normalize import (
+ MISSING,
+ OWNED,
+ classify_release,
+ is_various_artists,
+ normalize_artist,
+ normalize_title,
+)
+
+logger = logging.getLogger("homelabtoolkit.music_library")
+
+_OWNED_STATUSES = (OWNED, "probably_owned")
+_BATCH_SIZE = 200
+
+# Only one job of each kind runs at a time (single process).
+_running: set[str] = set()
+_running_lock = threading.Lock()
+
+
+# ── job guards ────────────────────────────────────────────────────────────────
+
+
+def _try_acquire(kind: str) -> bool:
+ with _running_lock:
+ if kind in _running:
+ return False
+ _running.add(kind)
+ return True
+
+
+def _release(kind: str) -> None:
+ with _running_lock:
+ _running.discard(kind)
+
+
+def _is_running(kind: str) -> bool:
+ with _running_lock:
+ return kind in _running
+
+
+# ── scan run bookkeeping ──────────────────────────────────────────────────────
+
+
+def _create_run(kind: str) -> int:
+ with db.connect() as conn:
+ cur = conn.execute(
+ "INSERT INTO library_scan_runs(kind, status, started_at) VALUES(?, 'running', ?)",
+ (kind, db.now_iso()),
+ )
+ return cur.lastrowid
+
+
+def _update_run(run_id: int, **fields) -> None:
+ if not fields:
+ return
+ cols = ", ".join(f"{k}=?" for k in fields)
+ with db.connect() as conn:
+ conn.execute(f"UPDATE library_scan_runs SET {cols} WHERE id=?", (*fields.values(), run_id))
+
+
+def _finish_run(run_id: int, status: str, **fields) -> None:
+ _update_run(run_id, status=status, completed_at=db.now_iso(), **fields)
+
+
+# ── tag reading ───────────────────────────────────────────────────────────────
+
+
+def _read_tags(path: Path) -> dict | None:
+ audio = _load_audio(path)
+ if audio is None:
+ return None
+ album_artist = _first_tag(audio, ["albumartist", "album artist"])
+ track_artist = _first_tag(audio, ["artist", "albumartist"])
+ artist = album_artist or track_artist or clean_name(path.parent.parent.name)
+ album = _first_tag(audio, ["album"]) or clean_name(path.parent.name)
+ title = _first_tag(audio, ["title"]) or path.stem
+ track_no = clean_track_number(audio.get("tracknumber"))
+ disc_no = clean_track_number(audio.get("discnumber"))
+ return {
+ "artist": artist,
+ "album": album,
+ "title": title,
+ "track_number": int(track_no) if track_no else None,
+ "disc_number": int(disc_no) if disc_no else None,
+ "year": int(extract_year(_first_tag(audio, ["date", "originaldate", "year"])) or 0),
+ "artist_mbid": _first_tag(audio, ["musicbrainz_artistid"]),
+ "album_mbid": _first_tag(audio, ["musicbrainz_releasegroupid", "musicbrainz_albumid"]),
+ "track_mbid": _first_tag(audio, ["musicbrainz_trackid"]),
+ }
+
+
+def _iter_audio_files(root: Path):
+ """Yield audio file paths lazily so we never hold the library in memory."""
+ for dirpath, _dirnames, filenames in os.walk(root):
+ for name in filenames:
+ if Path(name).suffix.lower() in AUDIO_EXTENSIONS:
+ yield Path(dirpath) / name
+
+
+# ── upserts (in-run caches keep artist/album lookups cheap) ───────────────────
+
+
+def _get_artist_id(conn, cache: dict, meta: dict) -> int:
+ name = meta["artist"]
+ norm = normalize_artist(name)
+ if norm in cache:
+ return cache[norm]
+ now = db.now_iso()
+ various = 1 if is_various_artists(name) else 0
+ row = conn.execute("SELECT id FROM library_artists WHERE name_normalized=?", (norm,)).fetchone()
+ if row:
+ conn.execute(
+ "UPDATE library_artists SET is_active=1, is_various=?, updated_at=?, mbid=COALESCE(mbid, ?) WHERE id=?",
+ (various, now, meta.get("artist_mbid"), row["id"]),
+ )
+ artist_id = row["id"]
+ else:
+ cur = conn.execute(
+ "INSERT INTO library_artists(name, name_normalized, mbid, is_various, is_active, created_at, updated_at) "
+ "VALUES(?,?,?,?,1,?,?)",
+ (name, norm, meta.get("artist_mbid"), various, now, now),
+ )
+ artist_id = cur.lastrowid
+ cache[norm] = artist_id
+ return artist_id
+
+
+def _get_album_id(conn, cache: dict, artist_id: int, meta: dict) -> int:
+ title = meta["album"]
+ norm = normalize_title(title)
+ year = meta.get("year") or 0
+ key = (artist_id, norm, year)
+ if key in cache:
+ return cache[key]
+ now = db.now_iso()
+ row = conn.execute(
+ "SELECT id FROM library_albums WHERE artist_id=? AND title_normalized=? AND year=?",
+ (artist_id, norm, year),
+ ).fetchone()
+ if row:
+ conn.execute(
+ "UPDATE library_albums SET is_active=1, updated_at=?, mbid=COALESCE(mbid, ?) WHERE id=?",
+ (now, meta.get("album_mbid"), row["id"]),
+ )
+ album_id = row["id"]
+ else:
+ cur = conn.execute(
+ "INSERT INTO library_albums(artist_id, title, title_normalized, year, mbid, is_active, created_at, updated_at) "
+ "VALUES(?,?,?,?,?,1,?,?)",
+ (artist_id, title, norm, year, meta.get("album_mbid"), now, now),
+ )
+ album_id = cur.lastrowid
+ cache[key] = album_id
+ return album_id
+
+
+def _upsert_track(conn, album_id: int, meta: dict, path: Path, size: int, mtime: float, run_id: int) -> None:
+ now = db.now_iso()
+ conn.execute(
+ """
+ INSERT INTO library_tracks(album_id, title, track_number, disc_number, file_path, file_mtime,
+ file_size, mbid, is_active, last_seen_scan_id, created_at, updated_at)
+ VALUES(?,?,?,?,?,?,?,?,1,?,?,?)
+ ON CONFLICT(file_path) DO UPDATE SET
+ album_id=excluded.album_id, title=excluded.title, track_number=excluded.track_number,
+ disc_number=excluded.disc_number, file_mtime=excluded.file_mtime, file_size=excluded.file_size,
+ mbid=excluded.mbid, is_active=1, last_seen_scan_id=excluded.last_seen_scan_id, updated_at=excluded.updated_at
+ """,
+ (
+ album_id, meta["title"], meta["track_number"], meta["disc_number"], str(path), mtime,
+ size, meta.get("track_mbid"), run_id, now, now,
+ ),
+ )
+
+
+# ── scanner ───────────────────────────────────────────────────────────────────
+
+
+def run_scan(root: Path | None = None) -> dict:
+ """Walk the library and upsert artists/albums/tracks. Unchanged files
+ (same path + size + mtime) are skipped without reading tags. Files that
+ vanished are marked inactive, never hard-deleted."""
+ root = Path(root) if root else MUSIC_ROOT
+ run_id = _create_run("scan")
+ logger.info("Scan %d started: %s", run_id, root)
+
+ if not root.exists():
+ _finish_run(run_id, "failed", error_message=f"Music root not found: {root}")
+ logger.warning("Scan %d aborted: root missing", run_id)
+ return {"run_id": run_id, "status": "failed"}
+
+ files_scanned = 0
+ try:
+ with db.connect() as conn:
+ artist_cache: dict = {}
+ album_cache: dict = {}
+ batch = 0
+ for path in _iter_audio_files(root):
+ try:
+ stat = path.stat()
+ except OSError:
+ continue
+ size, mtime = stat.st_size, stat.st_mtime
+ existing = conn.execute(
+ "SELECT id, file_size, file_mtime FROM library_tracks WHERE file_path=?",
+ (str(path),),
+ ).fetchone()
+ if existing and existing["file_size"] == size and abs((existing["file_mtime"] or 0) - mtime) < 1:
+ conn.execute(
+ "UPDATE library_tracks SET is_active=1, last_seen_scan_id=? WHERE id=?",
+ (run_id, existing["id"]),
+ )
+ else:
+ meta = _read_tags(path)
+ if meta is not None:
+ artist_id = _get_artist_id(conn, artist_cache, meta)
+ album_id = _get_album_id(conn, album_cache, artist_id, meta)
+ _upsert_track(conn, album_id, meta, path, size, mtime, run_id)
+ files_scanned += 1
+ batch += 1
+ if batch >= _BATCH_SIZE:
+ conn.commit()
+ batch = 0
+ conn.execute(
+ "UPDATE library_scan_runs SET files_scanned=?, progress=? WHERE id=?",
+ (files_scanned, f"Scanned {files_scanned} files", run_id),
+ )
+ conn.commit()
+ conn.commit()
+
+ # Mark vanished files inactive, then cascade activity up.
+ conn.execute(
+ "UPDATE library_tracks SET is_active=0 WHERE COALESCE(last_seen_scan_id, -1) != ? AND is_active=1",
+ (run_id,),
+ )
+ conn.execute(
+ "UPDATE library_albums SET is_active = "
+ "CASE WHEN EXISTS(SELECT 1 FROM library_tracks t WHERE t.album_id=library_albums.id AND t.is_active=1) "
+ "THEN 1 ELSE 0 END"
+ )
+ conn.execute(
+ "UPDATE library_artists SET is_active = "
+ "CASE WHEN EXISTS(SELECT 1 FROM library_albums al WHERE al.artist_id=library_artists.id AND al.is_active=1) "
+ "THEN 1 ELSE 0 END"
+ )
+ artists_found = conn.execute("SELECT COUNT(*) c FROM library_artists WHERE is_active=1").fetchone()["c"]
+ albums_found = conn.execute("SELECT COUNT(*) c FROM library_albums WHERE is_active=1").fetchone()["c"]
+ conn.commit()
+
+ _finish_run(
+ run_id, "completed",
+ files_scanned=files_scanned, albums_found=albums_found, artists_found=artists_found,
+ progress="Done",
+ )
+ logger.info("Scan %d completed: %d files, %d artists, %d albums", run_id, files_scanned, artists_found, albums_found)
+ return {"run_id": run_id, "status": "completed", "files_scanned": files_scanned}
+ except Exception as exc: # pragma: no cover - defensive
+ logger.exception("Scan %d failed", run_id)
+ _finish_run(run_id, "failed", files_scanned=files_scanned, error_message=str(exc))
+ return {"run_id": run_id, "status": "failed", "error": str(exc)}
+
+
+# ── completeness ──────────────────────────────────────────────────────────────
+
+
+def _local_albums_for_artist(conn, artist_id: int) -> list[dict]:
+ rows = conn.execute(
+ "SELECT id, title, title_normalized, year, mbid FROM library_albums WHERE artist_id=? AND is_active=1",
+ (artist_id,),
+ ).fetchall()
+ return [dict(r) for r in rows]
+
+
+def recompute_completeness(conn, artist_id: int) -> None:
+ """Rebuild completeness rows for one artist from stored external releases and
+ local albums. Manual decisions (manual_override=1) are preserved."""
+ local_albums = _local_albums_for_artist(conn, artist_id)
+ releases = conn.execute(
+ "SELECT mb_release_group_mbid, title, first_release_year, primary_type, secondary_types "
+ "FROM external_releases WHERE artist_id=?",
+ (artist_id,),
+ ).fetchall()
+
+ existing = {
+ r["release_group_mbid"]: dict(r)
+ for r in conn.execute(
+ "SELECT release_group_mbid, manual_override FROM collection_completeness WHERE artist_id=?",
+ (artist_id,),
+ ).fetchall()
+ }
+
+ qualifying_mbids: list[str] = []
+ now = db.now_iso()
+ for rel in releases:
+ secondary = json.loads(rel["secondary_types"] or "[]")
+ if not musicbrainz.is_official_album(rel["primary_type"], secondary):
+ continue
+ mbid = rel["mb_release_group_mbid"]
+ qualifying_mbids.append(mbid)
+
+ prior = existing.get(mbid)
+ if prior and prior["manual_override"]:
+ # Keep the user's decision; only refresh descriptive fields.
+ conn.execute(
+ "UPDATE collection_completeness SET title=?, year=?, source='musicbrainz', updated_at=? "
+ "WHERE artist_id=? AND release_group_mbid=?",
+ (rel["title"], rel["first_release_year"] or 0, now, artist_id, mbid),
+ )
+ continue
+
+ status, confidence, reason, local_id = classify_release(
+ rel["title"], rel["first_release_year"], mbid, local_albums
+ )
+ conn.execute(
+ """
+ INSERT INTO collection_completeness(artist_id, release_group_mbid, local_album_id, title, year,
+ status, confidence, reason, source, manual_override, updated_at)
+ VALUES(?,?,?,?,?,?,?,?, 'musicbrainz', 0, ?)
+ ON CONFLICT(artist_id, release_group_mbid) DO UPDATE SET
+ local_album_id=excluded.local_album_id, title=excluded.title, year=excluded.year,
+ status=excluded.status, confidence=excluded.confidence, reason=excluded.reason,
+ source='musicbrainz', updated_at=excluded.updated_at
+ WHERE collection_completeness.manual_override=0
+ """,
+ (artist_id, mbid, local_id, rel["title"], rel["first_release_year"] or 0,
+ status, confidence, reason, now),
+ )
+
+ # Drop non-manual rows that are no longer qualifying (e.g. filter changes).
+ placeholders = ",".join("?" for _ in qualifying_mbids) or "''"
+ conn.execute(
+ f"DELETE FROM collection_completeness WHERE artist_id=? AND manual_override=0 "
+ f"AND release_group_mbid NOT IN ({placeholders})",
+ (artist_id, *qualifying_mbids),
+ )
+
+
+# ── metadata refresh job (per-artist MusicBrainz lookups) ─────────────────────
+
+
+def run_metadata_refresh() -> dict:
+ run_id = _create_run("metadata")
+ logger.info("Metadata refresh %d started", run_id)
+ processed = 0
+ try:
+ with db.connect() as conn:
+ artists = conn.execute(
+ "SELECT id, name, mbid, is_various FROM library_artists WHERE is_active=1 ORDER BY name"
+ ).fetchall()
+ total = len(artists)
+
+ for artist in artists:
+ artist_id = artist["id"]
+ if artist["is_various"]:
+ with db.connect() as conn:
+ conn.execute(
+ "INSERT INTO external_artist_matches(artist_id, status, checked_at) VALUES(?, 'skipped', ?) "
+ "ON CONFLICT(artist_id) DO UPDATE SET status='skipped', checked_at=excluded.checked_at",
+ (artist_id, db.now_iso()),
+ )
+ processed += 1
+ continue
+
+ match = musicbrainz.search_artist(artist["name"])
+ with db.connect() as conn:
+ if not match or not match.get("mbid"):
+ conn.execute(
+ "INSERT INTO external_artist_matches(artist_id, status, checked_at) VALUES(?, 'not_found', ?) "
+ "ON CONFLICT(artist_id) DO UPDATE SET status='not_found', checked_at=excluded.checked_at",
+ (artist_id, db.now_iso()),
+ )
+ processed += 1
+ _bump_metadata_progress(run_id, processed, total)
+ continue
+ conn.execute(
+ "INSERT INTO external_artist_matches(artist_id, mb_artist_mbid, mb_artist_name, confidence, status, checked_at) "
+ "VALUES(?,?,?,?, 'matched', ?) "
+ "ON CONFLICT(artist_id) DO UPDATE SET mb_artist_mbid=excluded.mb_artist_mbid, "
+ "mb_artist_name=excluded.mb_artist_name, confidence=excluded.confidence, status='matched', checked_at=excluded.checked_at",
+ (artist_id, match["mbid"], match["name"], match["confidence"], db.now_iso()),
+ )
+
+ release_groups = musicbrainz.fetch_release_groups(match["mbid"])
+ with db.connect() as conn:
+ for rg in release_groups:
+ if not rg.get("mbid"):
+ continue
+ conn.execute(
+ """
+ INSERT INTO external_releases(artist_id, mb_release_group_mbid, title, title_normalized,
+ first_release_year, primary_type, secondary_types, fetched_at)
+ VALUES(?,?,?,?,?,?,?,?)
+ ON CONFLICT(artist_id, mb_release_group_mbid) DO UPDATE SET
+ title=excluded.title, title_normalized=excluded.title_normalized,
+ first_release_year=excluded.first_release_year, primary_type=excluded.primary_type,
+ secondary_types=excluded.secondary_types, fetched_at=excluded.fetched_at
+ """,
+ (
+ artist_id, rg["mbid"], rg["title"], normalize_title(rg["title"]),
+ rg["first_release_year"], rg["primary_type"], json.dumps(rg["secondary_types"]),
+ db.now_iso(),
+ ),
+ )
+ recompute_completeness(conn, artist_id)
+
+ processed += 1
+ _bump_metadata_progress(run_id, processed, total)
+
+ _finish_run(run_id, "completed", artists_found=processed, progress=f"Checked {processed} artists")
+ logger.info("Metadata refresh %d completed: %d artists", run_id, processed)
+ return {"run_id": run_id, "status": "completed", "artists": processed}
+ except Exception as exc: # pragma: no cover - defensive
+ logger.exception("Metadata refresh %d failed", run_id)
+ _finish_run(run_id, "failed", error_message=str(exc))
+ return {"run_id": run_id, "status": "failed", "error": str(exc)}
+
+
+def _bump_metadata_progress(run_id: int, processed: int, total: int) -> None:
+ _update_run(run_id, artists_found=processed, progress=f"Checked {processed}/{total} artists")
+
+
+# ── job launchers ─────────────────────────────────────────────────────────────
+
+
+def start_scan_job(root: Path | None = None) -> dict:
+ if not _try_acquire("scan"):
+ return {"started": False, "reason": "A scan is already running."}
+
+ def _worker():
+ try:
+ run_scan(root)
+ finally:
+ _release("scan")
+
+ threading.Thread(target=_worker, name="scan_music_collection", daemon=True).start()
+ return {"started": True}
+
+
+def start_metadata_job() -> dict:
+ if not _try_acquire("metadata"):
+ return {"started": False, "reason": "A metadata refresh is already running."}
+
+ def _worker():
+ try:
+ run_metadata_refresh()
+ finally:
+ _release("metadata")
+
+ threading.Thread(target=_worker, name="refresh_music_metadata", daemon=True).start()
+ return {"started": True}
+
+
+# ── read-side queries (UI; database only) ─────────────────────────────────────
+
+
+def _latest_run(conn, kind: str) -> dict | None:
+ row = conn.execute(
+ "SELECT * FROM library_scan_runs WHERE kind=? ORDER BY id DESC LIMIT 1", (kind,)
+ ).fetchone()
+ return dict(row) if row else None
+
+
+def get_status() -> dict:
+ with db.connect() as conn:
+ scan = _latest_run(conn, "scan")
+ metadata = _latest_run(conn, "metadata")
+ return {
+ "scan": scan,
+ "metadata": metadata,
+ "scan_running": _is_running("scan"),
+ "metadata_running": _is_running("metadata"),
+ }
+
+
+def get_overview() -> dict:
+ with db.connect() as conn:
+ scan = _latest_run(conn, "scan")
+ metadata = _latest_run(conn, "metadata")
+ totals = conn.execute(
+ """
+ SELECT
+ SUM(CASE WHEN status IN ('owned','probably_owned') THEN 1 ELSE 0 END) AS owned,
+ SUM(CASE WHEN status='missing' THEN 1 ELSE 0 END) AS missing,
+ SUM(CASE WHEN status='uncertain' THEN 1 ELSE 0 END) AS uncertain,
+ SUM(CASE WHEN status='ignored' THEN 1 ELSE 0 END) AS ignored
+ FROM collection_completeness c
+ JOIN library_artists a ON a.id=c.artist_id AND a.is_active=1
+ """
+ ).fetchone()
+ artist_count = conn.execute("SELECT COUNT(*) c FROM library_artists WHERE is_active=1").fetchone()["c"]
+ album_count = conn.execute("SELECT COUNT(*) c FROM library_albums WHERE is_active=1").fetchone()["c"]
+
+ owned = totals["owned"] or 0
+ missing = totals["missing"] or 0
+ uncertain = totals["uncertain"] or 0
+ ignored = totals["ignored"] or 0
+ denom = owned + missing + uncertain
+ completeness = round(owned / denom * 100, 1) if denom else 0.0
+ return {
+ "last_scan": scan,
+ "last_metadata": metadata,
+ "scan_running": _is_running("scan"),
+ "metadata_running": _is_running("metadata"),
+ "owned": owned,
+ "missing": missing,
+ "uncertain": uncertain,
+ "ignored": ignored,
+ "completeness": completeness,
+ "library_artists": artist_count,
+ "library_albums": album_count,
+ }
+
+
+def get_artists_completeness(search: str = "") -> list[dict]:
+ where = "WHERE a.is_active=1"
+ params: list = []
+ if search.strip():
+ where += " AND a.name LIKE ?"
+ params.append(f"%{search.strip()}%")
+ with db.connect() as conn:
+ rows = conn.execute(
+ f"""
+ SELECT a.id, a.name,
+ SUM(CASE WHEN c.status IN ('owned','probably_owned') THEN 1 ELSE 0 END) AS owned,
+ SUM(CASE WHEN c.status='missing' THEN 1 ELSE 0 END) AS missing,
+ SUM(CASE WHEN c.status='uncertain' THEN 1 ELSE 0 END) AS uncertain,
+ SUM(CASE WHEN c.status='ignored' THEN 1 ELSE 0 END) AS ignored,
+ COUNT(c.id) AS total
+ FROM library_artists a
+ JOIN collection_completeness c ON c.artist_id=a.id
+ {where}
+ GROUP BY a.id
+ HAVING total > 0
+ ORDER BY missing DESC, a.name COLLATE NOCASE
+ """,
+ params,
+ ).fetchall()
+ result = []
+ for r in rows:
+ owned, missing, uncertain = r["owned"] or 0, r["missing"] or 0, r["uncertain"] or 0
+ denom = owned + missing + uncertain
+ result.append(
+ {
+ "id": r["id"],
+ "name": r["name"],
+ "owned": owned,
+ "missing": missing,
+ "uncertain": uncertain,
+ "ignored": r["ignored"] or 0,
+ "completeness": round(owned / denom * 100, 1) if denom else 0.0,
+ }
+ )
+ return result
+
+
+def get_artist_albums(artist_id: int) -> dict:
+ with db.connect() as conn:
+ artist = conn.execute("SELECT id, name FROM library_artists WHERE id=?", (artist_id,)).fetchone()
+ rows = conn.execute(
+ "SELECT id, release_group_mbid, title, year, status, confidence, reason, source, manual_override "
+ "FROM collection_completeness WHERE artist_id=? ORDER BY year, title COLLATE NOCASE",
+ (artist_id,),
+ ).fetchall()
+ return {
+ "artist": dict(artist) if artist else None,
+ "albums": [dict(r) for r in rows],
+ }
+
+
+def set_album_decision(completeness_id: int, action: str) -> dict:
+ action = (action or "").lower()
+ valid = {"ignore", "owned", "missing", "reset"}
+ if action not in valid:
+ raise ValueError(f"Unknown action: {action}")
+
+ now = db.now_iso()
+ with db.connect() as conn:
+ row = conn.execute(
+ "SELECT id, artist_id FROM collection_completeness WHERE id=?", (completeness_id,)
+ ).fetchone()
+ if not row:
+ raise LookupError("Completeness row not found.")
+
+ if action == "ignore":
+ conn.execute(
+ "UPDATE collection_completeness SET status='ignored', manual_override=1, reason='Manually ignored', updated_at=? WHERE id=?",
+ (now, completeness_id),
+ )
+ elif action == "owned":
+ conn.execute(
+ "UPDATE collection_completeness SET status='owned', confidence=1.0, manual_override=1, reason='Manually marked owned', updated_at=? WHERE id=?",
+ (now, completeness_id),
+ )
+ elif action == "missing":
+ conn.execute(
+ "UPDATE collection_completeness SET status='missing', confidence=0, manual_override=1, reason='Manually marked missing', updated_at=? WHERE id=?",
+ (now, completeness_id),
+ )
+ elif action == "reset":
+ conn.execute(
+ "UPDATE collection_completeness SET manual_override=0, updated_at=? WHERE id=?",
+ (now, completeness_id),
+ )
+ recompute_completeness(conn, row["artist_id"])
+ return {"status": "ok"}
diff --git a/services/musicbrainz.py b/services/musicbrainz.py
new file mode 100644
index 0000000..f34fc75
--- /dev/null
+++ b/services/musicbrainz.py
@@ -0,0 +1,187 @@
+"""MusicBrainz client: the default free metadata source.
+
+Deliberately small and polite:
+* one global throttle so we never exceed ~1 request/second (MB's published limit);
+* a descriptive User-Agent (MB rejects anonymous clients);
+* retry/backoff on 503 and network errors;
+* responses cached in the ``mb_cache`` table so completeness never hits the API
+ during a normal page render.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import os
+import threading
+import time
+
+import requests
+
+from . import db
+
+logger = logging.getLogger("homelabtoolkit.musicbrainz")
+
+MB_BASE = "https://musicbrainz.org/ws/2"
+USER_AGENT = os.environ.get(
+ "MUSICBRAINZ_USER_AGENT",
+ "HomelabToolkit/1.0 ( https://github.com/homelabtoolkit )",
+)
+MIN_INTERVAL_SECONDS = 1.1
+CACHE_MAX_AGE_DAYS = 30
+
+# Release-group filtering. Primary type must be Album; any of these secondary
+# types excludes it (live albums, compilations, soundtracks, remixes, etc.).
+# Kept as module constants so the filter can be relaxed later in one place.
+INCLUDED_PRIMARY_TYPES = {"album"}
+EXCLUDED_SECONDARY_TYPES = {
+ "live",
+ "compilation",
+ "soundtrack",
+ "remix",
+ "dj-mix",
+ "mixtape/street",
+ "demo",
+ "interview",
+ "audiobook",
+ "audio drama",
+ "spokenword",
+}
+
+_throttle_lock = threading.Lock()
+_last_request_at = 0.0
+
+
+def _throttle() -> None:
+ global _last_request_at
+ with _throttle_lock:
+ wait = MIN_INTERVAL_SECONDS - (time.monotonic() - _last_request_at)
+ if wait > 0:
+ time.sleep(wait)
+ _last_request_at = time.monotonic()
+
+
+def _cache_get(cache_key: str, max_age_days: int = CACHE_MAX_AGE_DAYS):
+ cutoff = time.time() - max_age_days * 86400
+ with db.connect() as conn:
+ row = conn.execute(
+ "SELECT payload, fetched_at FROM mb_cache WHERE cache_key=?", (cache_key,)
+ ).fetchone()
+ if not row:
+ return None
+ # fetched_at is ISO; treat anything older than the cutoff as a miss.
+ try:
+ import datetime as _dt
+
+ fetched = _dt.datetime.fromisoformat((row["fetched_at"] or "").replace("Z", "+00:00"))
+ if fetched.timestamp() < cutoff:
+ return None
+ except ValueError:
+ pass
+ try:
+ return json.loads(row["payload"])
+ except (TypeError, ValueError):
+ return None
+
+
+def _cache_put(cache_key: str, payload) -> None:
+ with db.connect() as conn:
+ conn.execute(
+ "INSERT INTO mb_cache(cache_key, payload, fetched_at) VALUES(?,?,?) "
+ "ON CONFLICT(cache_key) DO UPDATE SET payload=excluded.payload, fetched_at=excluded.fetched_at",
+ (cache_key, json.dumps(payload), db.now_iso()),
+ )
+
+
+def _request(path: str, params: dict, cache_key: str, *, use_cache: bool = True):
+ if use_cache:
+ cached = _cache_get(cache_key)
+ if cached is not None:
+ return cached
+
+ headers = {"User-Agent": USER_AGENT, "Accept": "application/json"}
+ query = {**params, "fmt": "json"}
+ for attempt in range(4):
+ _throttle()
+ try:
+ resp = requests.get(f"{MB_BASE}/{path}", params=query, headers=headers, timeout=25)
+ if resp.status_code == 503:
+ time.sleep(2.0 * (attempt + 1))
+ continue
+ resp.raise_for_status()
+ data = resp.json()
+ _cache_put(cache_key, data)
+ return data
+ except requests.RequestException as exc:
+ logger.warning("MusicBrainz request failed (%s attempt %d): %s", path, attempt + 1, exc)
+ time.sleep(1.5 * (attempt + 1))
+ return None
+
+
+def search_artist(name: str) -> dict | None:
+ """Best artist match for a name, with a 0–1 confidence score."""
+ cache_key = f"artist_search::{name.lower()}"
+ data = _request("artist", {"query": name, "limit": 5}, cache_key)
+ if not data:
+ return None
+ artists = data.get("artists") or []
+ if not artists:
+ return None
+ best = artists[0]
+ return {
+ "mbid": best.get("id"),
+ "name": best.get("name") or "",
+ "confidence": round(int(best.get("score", 0)) / 100.0, 3),
+ }
+
+
+def fetch_release_groups(artist_mbid: str) -> list[dict]:
+ """All release groups for an artist (paged). Filtering happens later so the
+ completeness filters can change without re-fetching."""
+ results: list[dict] = []
+ offset = 0
+ limit = 100
+ while True:
+ cache_key = f"release_groups::{artist_mbid}::{offset}"
+ data = _request(
+ "release-group",
+ {"artist": artist_mbid, "type": "album", "limit": limit, "offset": offset},
+ cache_key,
+ )
+ if not data:
+ break
+ batch = data.get("release-groups") or []
+ for rg in batch:
+ results.append(
+ {
+ "mbid": rg.get("id"),
+ "title": rg.get("title") or "",
+ "first_release_year": _year(rg.get("first-release-date")),
+ "primary_type": (rg.get("primary-type") or "").strip(),
+ "secondary_types": [s.strip() for s in (rg.get("secondary-types") or [])],
+ }
+ )
+ total = int(data.get("release-group-count", len(results)))
+ offset += limit
+ if offset >= total or not batch:
+ break
+ return results
+
+
+def is_official_album(primary_type: str | None, secondary_types: list[str] | None) -> bool:
+ """Apply the default album filter (excludes EP/single/live/comp/etc.)."""
+ if (primary_type or "").strip().lower() not in INCLUDED_PRIMARY_TYPES:
+ return False
+ for secondary in secondary_types or []:
+ if secondary.strip().lower() in EXCLUDED_SECONDARY_TYPES:
+ return False
+ return True
+
+
+def _year(date_str: str | None) -> int:
+ if not date_str:
+ return 0
+ try:
+ return int(str(date_str)[:4])
+ except (ValueError, TypeError):
+ return 0
diff --git a/services/navidrome.py b/services/navidrome.py
new file mode 100644
index 0000000..38a8595
--- /dev/null
+++ b/services/navidrome.py
@@ -0,0 +1,308 @@
+"""Navidrome integration via the Subsonic API.
+
+Navidrome speaks the Subsonic/OpenSubsonic REST API. Authentication uses the
+salted-token scheme (``t = md5(password + salt)``) so the password never travels
+in the clear. All functions take an ``httpx.AsyncClient`` so they share the
+app-wide client and stay easy to test.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import os
+import secrets
+
+import httpx
+
+NAVIDROME_URL = os.environ.get("NAVIDROME_URL", "http://10.0.0.2:4533")
+NAVIDROME_USER = os.environ.get("NAVIDROME_USER", "")
+NAVIDROME_PASSWORD = os.environ.get("NAVIDROME_PASSWORD", "")
+NAVIDROME_CLIENT = "HomelabToolkit"
+SUBSONIC_API_VERSION = "1.16.1"
+
+
+class NavidromeError(Exception):
+ def __init__(self, message: str, status: int = 502):
+ self.message = message
+ self.status = status
+ super().__init__(message)
+
+
+def is_configured() -> bool:
+ return bool(NAVIDROME_URL and NAVIDROME_USER and NAVIDROME_PASSWORD)
+
+
+def _require_configured() -> None:
+ if not is_configured():
+ raise NavidromeError(
+ "Navidrome is not configured. Set NAVIDROME_URL, NAVIDROME_USER and "
+ "NAVIDROME_PASSWORD.",
+ status=503,
+ )
+
+
+def _auth_params() -> dict:
+ salt = secrets.token_hex(8)
+ token = hashlib.md5((NAVIDROME_PASSWORD + salt).encode("utf-8")).hexdigest()
+ return {
+ "u": NAVIDROME_USER,
+ "t": token,
+ "s": salt,
+ "v": SUBSONIC_API_VERSION,
+ "c": NAVIDROME_CLIENT,
+ "f": "json",
+ }
+
+
+def _base_url() -> str:
+ return NAVIDROME_URL.rstrip("/")
+
+
+async def _call(client: httpx.AsyncClient, method: str, params: dict | None = None) -> dict:
+ _require_configured()
+ url = f"{_base_url()}/rest/{method}.view"
+ request_params = {**_auth_params(), **(params or {})}
+ try:
+ response = await client.get(url, params=request_params)
+ except httpx.RequestError as exc:
+ raise NavidromeError(f"Could not reach Navidrome at {NAVIDROME_URL}: {exc}") from exc
+
+ try:
+ response.raise_for_status()
+ except httpx.HTTPStatusError as exc:
+ raise NavidromeError(
+ f"Navidrome returned HTTP {response.status_code} for {method}.", status=502
+ ) from exc
+
+ try:
+ payload = response.json().get("subsonic-response", {})
+ except ValueError as exc:
+ raise NavidromeError("Navidrome returned an invalid response.", status=502) from exc
+
+ if payload.get("status") != "ok":
+ error = payload.get("error") or {}
+ message = error.get("message") or "Navidrome request failed."
+ code = error.get("code")
+ # Subsonic auth failures (codes 40/41) are the user's credentials, not a
+ # server error — surface as 401 so the UI can prompt for setup.
+ status = 401 if code in (40, 41, 44) else 502
+ raise NavidromeError(message, status=status)
+
+ return payload
+
+
+def _cover_url(cover_art: str | None) -> str | None:
+ if not cover_art:
+ return None
+ return f"/api/navidrome/cover/{cover_art}"
+
+
+def _map_album(raw: dict) -> dict:
+ return {
+ "id": raw.get("id"),
+ "name": raw.get("name") or raw.get("album") or "",
+ "artist": raw.get("artist") or "",
+ "artist_id": raw.get("artistId"),
+ "year": raw.get("year"),
+ "genre": raw.get("genre"),
+ "song_count": raw.get("songCount") or 0,
+ "duration": raw.get("duration") or 0,
+ "cover_art": raw.get("coverArt"),
+ "cover_url": _cover_url(raw.get("coverArt")),
+ "created": raw.get("created"),
+ "starred": bool(raw.get("starred")),
+ }
+
+
+def _map_song(raw: dict) -> dict:
+ return {
+ "id": raw.get("id"),
+ "title": raw.get("title") or "",
+ "track": raw.get("track"),
+ "disc": raw.get("discNumber"),
+ "artist": raw.get("artist") or "",
+ "album": raw.get("album") or "",
+ "year": raw.get("year"),
+ "duration": raw.get("duration") or 0,
+ "bitrate": raw.get("bitRate"),
+ "suffix": raw.get("suffix"),
+ "size": raw.get("size"),
+ "path": raw.get("path"),
+ }
+
+
+async def ping(client: httpx.AsyncClient) -> dict:
+ if not is_configured():
+ return {"connected": False, "configured": False, "url": NAVIDROME_URL}
+ try:
+ payload = await _call(client, "ping")
+ return {
+ "connected": True,
+ "configured": True,
+ "url": NAVIDROME_URL,
+ "version": payload.get("version"),
+ "server": payload.get("type") or payload.get("serverVersion"),
+ }
+ except NavidromeError as exc:
+ return {
+ "connected": False,
+ "configured": True,
+ "url": NAVIDROME_URL,
+ "error": exc.message,
+ }
+
+
+async def get_artists(client: httpx.AsyncClient) -> list[dict]:
+ payload = await _call(client, "getArtists")
+ indexes = ((payload.get("artists") or {}).get("index")) or []
+ artists: list[dict] = []
+ for index in indexes:
+ for artist in index.get("artist") or []:
+ artists.append(
+ {
+ "id": artist.get("id"),
+ "name": artist.get("name") or "",
+ "album_count": artist.get("albumCount") or 0,
+ "cover_art": artist.get("coverArt"),
+ "cover_url": _cover_url(artist.get("coverArt")),
+ }
+ )
+ artists.sort(key=lambda entry: entry["name"].lower())
+ return artists
+
+
+async def get_albums(
+ client: httpx.AsyncClient,
+ *,
+ list_type: str = "alphabeticalByName",
+ size: int = 100,
+ offset: int = 0,
+) -> list[dict]:
+ payload = await _call(
+ client,
+ "getAlbumList2",
+ {"type": list_type, "size": size, "offset": offset},
+ )
+ raw_albums = ((payload.get("albumList2") or {}).get("album")) or []
+ return [_map_album(album) for album in raw_albums]
+
+
+async def search_albums(client: httpx.AsyncClient, query: str, *, count: int = 60) -> list[dict]:
+ payload = await _call(
+ client,
+ "search3",
+ {"query": query, "albumCount": count, "artistCount": 0, "songCount": 0},
+ )
+ raw_albums = ((payload.get("searchResult3") or {}).get("album")) or []
+ return [_map_album(album) for album in raw_albums]
+
+
+async def get_album(client: httpx.AsyncClient, album_id: str) -> dict:
+ payload = await _call(client, "getAlbum", {"id": album_id})
+ raw = payload.get("album") or {}
+ album = _map_album(raw)
+ album["songs"] = [_map_song(song) for song in (raw.get("song") or [])]
+ return album
+
+
+async def get_cover_art(
+ client: httpx.AsyncClient, cover_id: str, size: int | None = None
+) -> tuple[bytes, str]:
+ _require_configured()
+ url = f"{_base_url()}/rest/getCoverArt.view"
+ params = {**_auth_params(), "id": cover_id}
+ if size:
+ params["size"] = size
+ try:
+ response = await client.get(url, params=params)
+ except httpx.RequestError as exc:
+ raise NavidromeError(f"Could not reach Navidrome at {NAVIDROME_URL}: {exc}") from exc
+ if response.status_code != 200:
+ raise NavidromeError("Cover art not found.", status=404)
+ content_type = (response.headers.get("content-type") or "image/jpeg").split(";")[0]
+ if not content_type.startswith("image/"):
+ # Subsonic returns a JSON error document on failure.
+ raise NavidromeError("Cover art not found.", status=404)
+ return response.content, content_type
+
+
+async def start_scan(client: httpx.AsyncClient, *, full: bool = False) -> dict:
+ """Trigger a Navidrome library scan (Subsonic ``startScan`` extension)."""
+ payload = await _call(client, "startScan", {"fullScan": "true" if full else "false"})
+ status = payload.get("scanStatus") or {}
+ return {"scanning": bool(status.get("scanning")), "count": status.get("count")}
+
+
+async def get_genres(client: httpx.AsyncClient) -> list[dict]:
+ payload = await _call(client, "getGenres")
+ raw = ((payload.get("genres") or {}).get("genre")) or []
+ genres = [
+ {
+ "name": g.get("value") or g.get("name") or "Unknown",
+ "song_count": g.get("songCount") or 0,
+ "album_count": g.get("albumCount") or 0,
+ }
+ for g in raw
+ ]
+ genres.sort(key=lambda g: g["song_count"], reverse=True)
+ return genres
+
+
+async def get_format_breakdown(
+ client: httpx.AsyncClient, *, page_size: int = 500, max_pages: int = 400
+) -> dict:
+ """Count tracks by file format (flac, mp3, m4a, …) by paging all songs.
+
+ Subsonic has no aggregate format endpoint, so we walk ``search3`` with an
+ empty query (Navidrome returns the whole library) and tally each song's
+ ``suffix``. Cap the page count so a runaway library can't loop forever.
+ """
+ counts: dict[str, int] = {}
+ offset = 0
+ for _ in range(max_pages):
+ payload = await _call(
+ client,
+ "search3",
+ {
+ "query": "",
+ "artistCount": 0,
+ "albumCount": 0,
+ "songCount": page_size,
+ "songOffset": offset,
+ },
+ )
+ songs = ((payload.get("searchResult3") or {}).get("song")) or []
+ if not songs:
+ break
+ for song in songs:
+ suffix = (song.get("suffix") or "").lower() or "other"
+ counts[suffix] = counts.get(suffix, 0) + 1
+ if len(songs) < page_size:
+ break
+ offset += page_size
+
+ total = sum(counts.values())
+ formats = sorted(
+ ({"format": fmt, "count": count} for fmt, count in counts.items()),
+ key=lambda entry: entry["count"],
+ reverse=True,
+ )
+ return {"total": total, "formats": formats}
+
+
+async def get_stats(client: httpx.AsyncClient) -> dict:
+ """Library stats for the dashboard: artists, albums, tracks and genres."""
+ artists = await get_artists(client)
+ album_count = sum(artist["album_count"] for artist in artists)
+ try:
+ genres = await get_genres(client)
+ except NavidromeError:
+ genres = []
+ song_count = sum(g["song_count"] for g in genres)
+ return {
+ "artist_count": len(artists),
+ "album_count": album_count,
+ "song_count": song_count,
+ "genre_count": len(genres),
+ "top_genres": genres[:8],
+ }
diff --git a/services/recommendations.py b/services/recommendations.py
new file mode 100644
index 0000000..876176d
--- /dev/null
+++ b/services/recommendations.py
@@ -0,0 +1,159 @@
+"""Deterministic, watch-history-based recommendation engine.
+
+The scoring model is intentionally simple and explainable (section 8 of the
+spec). It is pure: ``build_profile`` and ``score_candidate`` touch no I/O and are
+the unit under test. ``build_candidates`` is the only function that calls Emby.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+
+from .emby_collections import ITEM_FIELDS, normalize_item
+
+# Points awarded per matching facet. Genre is the strongest signal.
+SCORE_GENRE = 5 # per shared genre
+SCORE_SERIES = 4 # same series / franchise
+SCORE_DIRECTOR = 3 # per shared director
+SCORE_ACTOR = 2 # per shared actor
+SCORE_STUDIO = 2 # per shared studio
+SCORE_DECADE = 1 # release decade seen in history
+SCORE_MEDIA_TYPE = 1 # media type seen in history
+
+DEFAULT_TARGET_SIZE = 25
+_CANDIDATE_FETCH_LIMIT = 300
+_MAX_GENRE_QUERY = 8
+
+
+def _decade(year) -> int | None:
+ try:
+ return (int(year) // 10) * 10
+ except (TypeError, ValueError):
+ return None
+
+
+@dataclass
+class TasteProfile:
+ """Aggregated facets of a user's watch history."""
+
+ genres: set[str] = field(default_factory=set)
+ series: set[str] = field(default_factory=set)
+ directors: set[str] = field(default_factory=set)
+ actors: set[str] = field(default_factory=set)
+ studios: set[str] = field(default_factory=set)
+ decades: set[int] = field(default_factory=set)
+ media_types: set[str] = field(default_factory=set)
+
+ @property
+ def is_empty(self) -> bool:
+ return not (
+ self.genres or self.series or self.directors or self.actors
+ or self.studios or self.decades or self.media_types
+ )
+
+
+def build_profile(watched_items: list[dict]) -> TasteProfile:
+ """Aggregate normalized watched items into a :class:`TasteProfile`."""
+ profile = TasteProfile()
+ for item in watched_items:
+ profile.genres.update(item.get("genres") or [])
+ profile.directors.update(item.get("directors") or [])
+ profile.actors.update(item.get("actors") or [])
+ profile.studios.update(item.get("studios") or [])
+ profile.media_types.add(item.get("media_type") or item.get("type") or "")
+ series = item.get("series_name")
+ if series:
+ profile.series.add(series)
+ # A watched series is itself a "franchise" anchor for similar items.
+ if (item.get("type") == "Series") and item.get("title"):
+ profile.series.add(item["title"])
+ decade = _decade(item.get("year"))
+ if decade is not None:
+ profile.decades.add(decade)
+ profile.media_types.discard("")
+ return profile
+
+
+def score_candidate(candidate: dict, profile: TasteProfile) -> int:
+ """Score a candidate against the profile. Higher is more similar."""
+ score = 0
+ score += SCORE_GENRE * len(set(candidate.get("genres") or []) & profile.genres)
+ score += SCORE_DIRECTOR * len(set(candidate.get("directors") or []) & profile.directors)
+ score += SCORE_ACTOR * len(set(candidate.get("actors") or []) & profile.actors)
+ score += SCORE_STUDIO * len(set(candidate.get("studios") or []) & profile.studios)
+
+ series = candidate.get("series_name") or (candidate.get("title") if candidate.get("type") == "Series" else None)
+ if series and series in profile.series:
+ score += SCORE_SERIES
+
+ decade = _decade(candidate.get("year"))
+ if decade is not None and decade in profile.decades:
+ score += SCORE_DECADE
+
+ media_type = candidate.get("media_type") or candidate.get("type")
+ if media_type and media_type in profile.media_types:
+ score += SCORE_MEDIA_TYPE
+
+ return score
+
+
+def rank_candidates(candidates: list[dict], profile: TasteProfile) -> list[dict]:
+ """Score, filter to score > 0, and sort candidates.
+
+ Order: score desc, then community rating desc, then title asc. Each returned
+ item carries an added ``score`` key for transparency in the UI/logs.
+ """
+ scored = []
+ for candidate in candidates:
+ score = score_candidate(candidate, profile)
+ if score <= 0:
+ continue
+ scored.append({**candidate, "score": score})
+ scored.sort(
+ key=lambda c: (-c["score"], -(c.get("community_rating") or 0.0), (c.get("title") or "").casefold())
+ )
+ return scored
+
+
+async def build_candidates(client, user_id: str, profile: TasteProfile, exclude_ids: set[str]) -> list[dict]:
+ """Fetch unplayed library items similar to the profile, minus exclusions.
+
+ Uses the user-scoped ``IsUnplayed`` filter so already-watched items never
+ enter the pool. Anything in ``exclude_ids`` (playlist members, defensively
+ re-checked watched ids) is dropped.
+ """
+ if profile.is_empty:
+ return []
+
+ genres = sorted(profile.genres)[:_MAX_GENRE_QUERY]
+ media_types = ",".join(sorted(t for t in profile.media_types if t)) or "Movie,Series"
+ params = {
+ "Recursive": "true",
+ "Filters": "IsUnplayed",
+ "IsPlayed": "false",
+ "IncludeItemTypes": media_types if media_types in ("Movie", "Series", "Movie,Series") else "Movie,Series",
+ "Fields": ITEM_FIELDS,
+ "EnableUserData": "true",
+ "SortBy": "CommunityRating",
+ "SortOrder": "Descending",
+ "Limit": str(_CANDIDATE_FETCH_LIMIT),
+ }
+ if genres:
+ # Emby treats "|" as OR across genre values.
+ params["Genres"] = "|".join(genres)
+
+ data = await client.get(f"/Users/{user_id}/Items", params)
+ raw_items = data.get("Items", []) if isinstance(data, dict) else (data or [])
+
+ seen: set[str] = set()
+ candidates: list[dict] = []
+ for raw in raw_items:
+ item = normalize_item(raw)
+ item_id = item["id"]
+ if not item_id or item_id in exclude_ids or item_id in seen:
+ continue
+ if item.get("watched"): # defensive: never recommend a watched item
+ continue
+ seen.add(item_id)
+ candidates.append(item)
+ return candidates
diff --git a/services/settings.py b/services/settings.py
new file mode 100644
index 0000000..fe09cc8
--- /dev/null
+++ b/services/settings.py
@@ -0,0 +1,63 @@
+"""Runtime settings store.
+
+Configuration can come from two places: environment variables (the deploy-time
+defaults) and a JSON file written by the in-app Settings page. The file, when
+present, wins. ``load`` returns the effective settings; ``save`` persists the
+editable subset and returns the new effective settings.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+from pathlib import Path
+
+SETTINGS_FILE = Path(os.environ.get("SETTINGS_FILE", "cache/settings.json"))
+
+FIELDS = (
+ "emby_url",
+ "emby_api_key",
+ "navidrome_url",
+ "navidrome_user",
+ "navidrome_password",
+ "music_root",
+)
+
+
+def env_defaults() -> dict:
+ return {
+ "emby_url": os.environ.get("EMBY_URL", "http://10.0.0.2:8096"),
+ "emby_api_key": os.environ.get("EMBY_API_KEY", ""),
+ "navidrome_url": os.environ.get("NAVIDROME_URL", "http://10.0.0.2:4533"),
+ "navidrome_user": os.environ.get("NAVIDROME_USER", ""),
+ "navidrome_password": os.environ.get("NAVIDROME_PASSWORD", ""),
+ "music_root": os.environ.get("MUSIC_ROOT", r"\\Matt-htpc\d\Music"),
+ }
+
+
+def _read_file() -> dict:
+ if not SETTINGS_FILE.exists():
+ return {}
+ try:
+ data = json.loads(SETTINGS_FILE.read_text(encoding="utf-8"))
+ except (OSError, ValueError):
+ return {}
+ return {k: str(v) for k, v in data.items() if k in FIELDS and v is not None}
+
+
+def load() -> dict:
+ """Effective settings: env defaults overlaid with the saved file."""
+ values = env_defaults()
+ values.update(_read_file())
+ return values
+
+
+def save(updates: dict) -> dict:
+ """Persist the editable subset of ``updates`` and return effective settings."""
+ current = _read_file()
+ for key in FIELDS:
+ if key in updates and updates[key] is not None:
+ current[key] = str(updates[key]).strip()
+ SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True)
+ SETTINGS_FILE.write_text(json.dumps(current, indent=2), encoding="utf-8")
+ return load()
diff --git a/services/text_normalize.py b/services/text_normalize.py
new file mode 100644
index 0000000..5b2c637
--- /dev/null
+++ b/services/text_normalize.py
@@ -0,0 +1,145 @@
+"""Name normalisation and fuzzy matching for collection completeness.
+
+Normalisation never mutates the stored original — callers keep both the original
+and normalised values. The point is to make "Album (Deluxe Edition)" and
+"Album - 2009 Remaster" collapse to the same comparable key without losing the
+display name.
+"""
+
+from __future__ import annotations
+
+import re
+from difflib import SequenceMatcher
+
+_BRACKET_RE = re.compile(r"[\(\[\{].*?[\)\]\}]")
+_PUNCT_RE = re.compile(r"[^\w\s]", re.UNICODE)
+_WS_RE = re.compile(r"\s+")
+
+# Edition / remaster qualifiers stripped from album titles before comparison.
+_EDITION_PATTERNS = [
+ r"\bsuper deluxe( edition)?\b",
+ r"\bdeluxe( edition| version)?\b",
+ r"\bexpanded( edition| version)?\b",
+ r"\bspecial edition\b",
+ r"\bcollector'?s edition\b",
+ r"\blegacy edition\b",
+ r"\banniversary( edition)?\b",
+ r"\b\d{1,3}(st|nd|rd|th) anniversary\b",
+ r"\bremaster(ed)?\b",
+ r"\bre-?master(ed)?\b",
+ r"\breissue\b",
+ r"\bbonus track(s)?( version)?\b",
+ r"\bbonus edition\b",
+ r"\b\d{4} remaster\b",
+ r"\bexplicit( version)?\b",
+ r"\bclean( version)?\b",
+ r"\bmono\b",
+ r"\bstereo\b",
+ r"\bdisc \d+\b",
+ r"\bcd\d+\b",
+]
+_EDITION_RE = re.compile("|".join(_EDITION_PATTERNS), re.IGNORECASE)
+
+VARIOUS_ARTISTS = {
+ "various artists",
+ "various",
+ "va",
+ "v a",
+ "soundtrack",
+ "original soundtrack",
+}
+
+
+def _base_clean(text: str) -> str:
+ text = text.lower()
+ text = _BRACKET_RE.sub(" ", text) # drop (...) [...] {...}
+ text = _EDITION_RE.sub(" ", text) # drop edition/remaster words
+ text = text.replace("&", " and ")
+ text = _PUNCT_RE.sub(" ", text) # drop remaining punctuation
+ return _WS_RE.sub(" ", text).strip()
+
+
+def normalize_title(text: str | None) -> str:
+ if not text:
+ return ""
+ return _base_clean(text)
+
+
+def normalize_artist(text: str | None) -> str:
+ if not text:
+ return ""
+ cleaned = _base_clean(text)
+ if cleaned.startswith("the "):
+ cleaned = cleaned[4:]
+ return cleaned
+
+
+def is_various_artists(name: str | None) -> bool:
+ if not name:
+ return False
+ return normalize_artist(name) in VARIOUS_ARTISTS or _base_clean(name) in VARIOUS_ARTISTS
+
+
+def similarity(a: str | None, b: str | None) -> float:
+ na, nb = normalize_title(a), normalize_title(b)
+ if not na or not nb:
+ return 0.0
+ if na == nb:
+ return 1.0
+ return SequenceMatcher(None, na, nb).ratio()
+
+
+# ── Completeness classification ───────────────────────────────────────────────
+# Statuses: owned | probably_owned | missing | uncertain (ignored is manual).
+
+OWNED = "owned"
+PROBABLY_OWNED = "probably_owned"
+UNCERTAIN = "uncertain"
+MISSING = "missing"
+
+FUZZY_PROBABLE = 0.88
+FUZZY_UNCERTAIN = 0.60
+
+
+def classify_release(
+ ext_title: str,
+ ext_year: int | None,
+ ext_mbid: str | None,
+ local_albums: list[dict],
+) -> tuple[str, float, str, int | None]:
+ """Decide a status for one external release group against local albums.
+
+ ``local_albums`` items: ``{id, title, title_normalized, year, mbid}``.
+ Match order: MusicBrainz id → normalised title (+year) → fuzzy title.
+ Returns ``(status, confidence, reason, local_album_id|None)``.
+ """
+ ext_norm = normalize_title(ext_title)
+
+ # 1) Exact MusicBrainz id match.
+ if ext_mbid:
+ for la in local_albums:
+ if la.get("mbid") and la["mbid"] == ext_mbid:
+ return (OWNED, 1.0, "MusicBrainz ID match", la["id"])
+
+ # 2) Normalised title (with year corroboration).
+ if ext_norm:
+ for la in local_albums:
+ if la.get("title_normalized") == ext_norm:
+ ly, ey = la.get("year") or 0, ext_year or 0
+ if ly and ey and abs(ly - ey) <= 1:
+ return (OWNED, 0.95, "Title and year match", la["id"])
+ return (PROBABLY_OWNED, 0.85, "Normalised title match", la["id"])
+
+ # 3) Fuzzy title.
+ best_ratio, best = 0.0, None
+ for la in local_albums:
+ r = similarity(ext_title, la.get("title"))
+ if r > best_ratio:
+ best_ratio, best = r, la
+ if best is not None:
+ if best_ratio >= FUZZY_PROBABLE:
+ return (PROBABLY_OWNED, round(best_ratio, 3), f"Fuzzy title match ({best_ratio:.2f})", best["id"])
+ if best_ratio >= FUZZY_UNCERTAIN:
+ return (UNCERTAIN, round(best_ratio, 3), f"Weak title match ({best_ratio:.2f})", best["id"])
+
+ return (MISSING, 0.0, "No matching local album", None)
diff --git a/static/app-theme.css b/static/app-theme.css
new file mode 100644
index 0000000..9de3c90
--- /dev/null
+++ b/static/app-theme.css
@@ -0,0 +1,799 @@
+@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
+
+/* ============================================================================
+ EmbyToolkit — Tracearr-modeled design system
+ Authored as a single overriding layer. Tokens use :root:root so they win over
+ each page's legacy inline :root, recoloring existing var() usage for free.
+ ========================================================================== */
+
+:root:root {
+ /* Canvas + surfaces — near-black, cool cyan-tinted neutrals */
+ --bg: #0a0c0f;
+ --bg-2: #0c0f13;
+ --surface: #101419;
+ --surface2: #151a21;
+ --surface3: #1b222b;
+ --surface4: #232c37;
+
+ /* Lines */
+ --border: rgba(151, 167, 187, 0.12);
+ --border-strong: rgba(151, 167, 187, 0.22);
+ --border-active: #36d6e0;
+
+ /* Text */
+ --text: #e8edf3;
+ --text-2: #9aa7b6;
+ --text-3: #5e6b7b;
+
+ /* Accent — vivid cyan */
+ --accent: #36d6e0;
+ --accent-h: #5ee7ef;
+ --accent-2: #2bb6c4;
+ --accent-glow: rgba(54, 214, 224, 0.15);
+ --accent-soft: rgba(54, 214, 224, 0.12);
+
+ /* Status */
+ --green: #46d99a;
+ --green-bg: rgba(70, 217, 154, 0.12);
+ --green-bd: rgba(70, 217, 154, 0.30);
+ --amber: #f3c969;
+ --amber-bg: rgba(243, 201, 105, 0.12);
+ --amber-bd: rgba(243, 201, 105, 0.30);
+ --red: #f0726f;
+ --red-bg: rgba(240, 114, 111, 0.12);
+ --red-bd: rgba(240, 114, 111, 0.30);
+
+ /* Elevation + radii */
+ --shadow-soft: 0 14px 38px rgba(2, 5, 10, 0.42);
+ --shadow-strong: 0 26px 60px rgba(2, 5, 10, 0.55);
+ --r: 10px;
+ --r-lg: 14px;
+
+ --ease-out: cubic-bezier(0.22, 1, 0.36, 1);
+}
+
+html { color-scheme: dark; }
+
+body,
+input,
+button,
+select,
+textarea {
+ font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif !important;
+}
+
+body {
+ background:
+ radial-gradient(900px 420px at 78% -8%, rgba(54, 214, 224, 0.07), transparent 60%),
+ var(--bg) !important;
+ color: var(--text);
+}
+
+::selection { background: var(--accent-glow); color: var(--text); }
+
+/* Tabular numerics for data-ish fields */
+.results-count,
+.results-page,
+.selection-meta,
+.pager-meta,
+.toolbar-meta,
+.hero-badge,
+.preview-meta,
+.thumb-preview-meta,
+.primary-preview-note,
+.slider-val,
+.asset-count,
+.bd-counter,
+.stat-value,
+.cell-num,
+.trust-score {
+ font-variant-numeric: tabular-nums;
+ letter-spacing: 0.01em;
+}
+
+/* Scrollbars */
+* { scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent; }
+*::-webkit-scrollbar { width: 9px; height: 9px; }
+*::-webkit-scrollbar-thumb {
+ background: var(--border-strong);
+ border-radius: 99px;
+ border: 2px solid transparent;
+ background-clip: padding-box;
+}
+*::-webkit-scrollbar-thumb:hover { background: var(--text-3); background-clip: padding-box; }
+
+/* ============================================================================
+ Sidebar
+ ========================================================================== */
+
+.app-nav {
+ width: 236px !important;
+ background: var(--surface) !important;
+ border-right: 1px solid var(--border) !important;
+ backdrop-filter: blur(14px);
+}
+
+.app-nav-brand {
+ padding: 18px 16px 14px !important;
+ gap: 11px !important;
+ border-bottom: 1px solid var(--border) !important;
+}
+
+.app-nav-logo {
+ width: 30px !important;
+ height: 30px !important;
+ border-radius: 9px !important;
+ background: linear-gradient(155deg, #5ee7ef 0%, #36d6e0 45%, #1f9aa6 100%) !important;
+ color: #04181b !important;
+ box-shadow:
+ inset 0 1px 0 rgba(255, 255, 255, 0.35),
+ 0 4px 14px rgba(54, 214, 224, 0.28) !important;
+}
+
+.app-nav-name {
+ font-size: 15px !important;
+ font-weight: 700 !important;
+ letter-spacing: -0.02em !important;
+ color: var(--text) !important;
+}
+
+/* Emby connection status chip (reuses #statusDot / #statusText) */
+.app-nav-status {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin: 12px 12px 4px;
+ padding: 8px 11px;
+ border-radius: 9px;
+ background: var(--surface2);
+ border: 1px solid var(--border);
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--text-2);
+}
+
+.app-nav-items {
+ padding: 8px 10px !important;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.app-nav-item {
+ position: relative;
+ min-height: 38px;
+ gap: 11px !important;
+ padding: 8px 12px !important;
+ border: 1px solid transparent;
+ border-radius: 9px !important;
+ font-size: 13px !important;
+ font-weight: 500 !important;
+ color: var(--text-2) !important;
+ transition: background 160ms var(--ease-out), color 160ms var(--ease-out) !important;
+}
+
+.app-nav-item svg { opacity: 0.9; }
+
+.app-nav-item:hover {
+ background: var(--surface2) !important;
+ border-color: transparent !important;
+ color: var(--text) !important;
+}
+
+.app-nav-item.active {
+ background: var(--accent-soft) !important;
+ border-color: transparent !important;
+ color: var(--accent-h) !important;
+}
+
+.app-nav-item.active svg { opacity: 1; }
+
+/* Inset indicator pill (not a side-stripe border) */
+.app-nav-item.active::before {
+ content: "";
+ position: absolute;
+ left: 4px;
+ top: 50%;
+ transform: translateY(-50%);
+ width: 3px;
+ height: 16px;
+ border-radius: 99px;
+ background: var(--accent);
+ box-shadow: 0 0 10px var(--accent-glow);
+}
+
+/* Footer: status + social + version */
+.app-nav-footer,
+.app-nav-foot {
+ padding: 12px 14px !important;
+ border-top: 1px solid var(--border) !important;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ flex-shrink: 0;
+}
+
+.app-nav-foot .app-nav-status { margin: 0; }
+
+.app-nav-social {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.app-nav-social a {
+ width: 30px;
+ height: 30px;
+ display: grid;
+ place-items: center;
+ border-radius: 8px;
+ color: var(--text-3);
+ transition: background 150ms var(--ease-out), color 150ms var(--ease-out);
+}
+
+.app-nav-social a:hover { background: var(--surface2); color: var(--accent-h); }
+
+.app-nav-version {
+ font-size: 11px;
+ color: var(--text-3);
+ letter-spacing: 0.02em;
+ font-variant-numeric: tabular-nums;
+}
+
+.dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--green) !important;
+ box-shadow: 0 0 0 3px var(--green-bg), 0 0 8px var(--green) !important;
+ flex-shrink: 0;
+}
+
+.dot.off { background: var(--red) !important; box-shadow: 0 0 0 3px var(--red-bg), 0 0 8px var(--red) !important; }
+
+/* ============================================================================
+ Secondary sidebar (search + results column on Generator / Collections)
+ One shared 16px gutter down the whole column so the search field, the
+ results-count row, every list item, and the pager align on the same left
+ edge. The header zone matches the primary nav brand height (64px) so the two
+ sidebars' top dividers line up across the seam.
+ ========================================================================== */
+
+.sidebar { background: rgba(16, 20, 25, 0.92) !important; }
+
+.search-wrap {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ gap: 10px;
+ min-height: 64px;
+ padding: 13px 16px !important;
+ border-bottom: 1px solid var(--border) !important;
+}
+
+.results-toolbar,
+.results-footer {
+ padding: 12px 16px !important;
+ gap: 10px;
+}
+
+.results-count {
+ font-size: 11px !important;
+ font-weight: 600 !important;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--text-3) !important;
+}
+
+.results-page { color: var(--text-3) !important; }
+
+.results { padding: 8px !important; }
+
+.results .result-item + .result-item { margin-top: 2px; }
+
+.result-item {
+ gap: 11px !important;
+ padding: 9px 8px !important; /* 8 (results) + 8 = 16px content gutter */
+ border-radius: 9px !important;
+}
+
+.result-poster {
+ width: 38px !important;
+ height: 57px !important;
+ border-radius: 6px !important;
+}
+
+.result-name { font-size: 13px !important; }
+
+.empty { padding: 48px 24px !important; line-height: 1.5; }
+
+/* Align the primary nav brand divider with the search-wrap divider */
+.app-nav-brand {
+ min-height: 64px;
+ padding: 0 16px !important;
+ align-items: center;
+}
+
+/* ============================================================================
+ Page chrome + header
+ ========================================================================== */
+
+.page,
+.main,
+.body { background: transparent !important; }
+
+.app-topbar {
+ background: rgba(16, 20, 25, 0.86) !important;
+ border-bottom: 1px solid var(--border) !important;
+ backdrop-filter: blur(14px);
+}
+
+.hero {
+ align-items: flex-start !important;
+}
+
+.hero-copy h2,
+.collection-name,
+.card-title,
+.panel-head h3 {
+ letter-spacing: -0.02em !important;
+ color: var(--text) !important;
+}
+
+.hero-copy h2 {
+ font-size: 28px !important;
+ font-weight: 700 !important;
+}
+
+.hero-copy p {
+ color: var(--text-2) !important;
+ max-width: 72ch;
+ line-height: 1.6;
+}
+
+.hero-copy p strong { color: var(--text); font-weight: 600; }
+
+.hero-badge {
+ align-self: center;
+ background: var(--surface2) !important;
+ border: 1px solid var(--border) !important;
+ border-radius: 999px !important;
+ color: var(--text-2) !important;
+ font-weight: 600;
+ letter-spacing: 0.02em;
+}
+
+/* ============================================================================
+ Surfaces — toolbars, panels, cards
+ ========================================================================== */
+
+.sidebar,
+.controls,
+.results-toolbar,
+.results-footer,
+.toolbar,
+.panel {
+ background: rgba(16, 20, 25, 0.88) !important;
+ border-color: var(--border) !important;
+ backdrop-filter: blur(14px);
+}
+
+.toolbar,
+.panel,
+.card,
+.preview-shell,
+.dropzone,
+.asset-card,
+.preview-frame,
+.primary-preview-frame {
+ border-radius: var(--r-lg) !important;
+ box-shadow: var(--shadow-soft);
+}
+
+.toolbar,
+.panel,
+.card {
+ background: linear-gradient(180deg, rgba(19, 24, 31, 0.96), rgba(13, 17, 22, 0.96)) !important;
+ border: 1px solid var(--border) !important;
+}
+
+.panel-head { border-color: var(--border) !important; }
+
+.preview-shell {
+ background:
+ radial-gradient(circle at top right, rgba(54, 214, 224, 0.1), transparent 32%),
+ linear-gradient(180deg, rgba(22, 28, 37, 0.94), rgba(11, 15, 21, 0.98)) !important;
+ border: 1px solid var(--border) !important;
+}
+
+.preview-frame,
+.primary-preview-frame {
+ background: #0d141c !important;
+ box-shadow: var(--shadow-strong) !important;
+}
+
+.hero-badge,
+.preview-meta,
+.thumb-preview-tools,
+.preview-tools,
+.thumb-preview-hint,
+.preview-hint,
+.primary-preview-hint,
+.bd-counter {
+ background: rgba(8, 12, 17, 0.8) !important;
+ border-color: var(--border-strong) !important;
+}
+
+/* ============================================================================
+ Stat cards (Tracearr summary row) — available vocabulary for the pages
+ ========================================================================== */
+
+.stat-row {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+ gap: 14px;
+}
+
+.stat-card {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ padding: 18px 20px;
+ border-radius: var(--r-lg);
+ background: linear-gradient(180deg, rgba(19, 24, 31, 0.96), rgba(13, 17, 22, 0.96));
+ border: 1px solid var(--border);
+ box-shadow: var(--shadow-soft);
+}
+
+.stat-icon {
+ width: 38px;
+ height: 38px;
+ border-radius: 10px;
+ display: grid;
+ place-items: center;
+ background: var(--accent-soft);
+ color: var(--accent-h);
+ flex-shrink: 0;
+}
+
+.stat-meta { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
+.stat-value { font-size: 22px; font-weight: 700; letter-spacing: -0.02em; color: var(--text); }
+.stat-label { font-size: 12px; color: var(--text-2); }
+
+/* ============================================================================
+ Segmented controls / date pills + dropdown buttons
+ ========================================================================== */
+
+.seg {
+ display: inline-flex;
+ padding: 3px;
+ gap: 2px;
+ border-radius: 10px;
+ background: var(--surface2);
+ border: 1px solid var(--border);
+}
+
+.seg-btn {
+ border: 0 !important;
+ background: transparent !important;
+ color: var(--text-2) !important;
+ padding: 6px 12px !important;
+ border-radius: 8px !important;
+ font-size: 12px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: background 150ms var(--ease-out), color 150ms var(--ease-out) !important;
+}
+
+.seg-btn:hover:not(:disabled) { color: var(--text) !important; background: transparent !important; }
+
+.seg-btn.active {
+ background: var(--surface4) !important;
+ color: var(--text) !important;
+ box-shadow: inset 0 0 0 1px var(--border-strong);
+}
+
+/* ============================================================================
+ Inputs + buttons
+ ========================================================================== */
+
+.search-input,
+.ctrl-input,
+.ctrl-select,
+.ctrl-textarea,
+.color-wrap input[type="text"],
+.color-wrap input[type="color"],
+.picker-row input[type="text"],
+.picker-row input[type="color"],
+.btn,
+.pager-btn,
+.tab-btn,
+.seg-btn,
+.slider-step,
+.asset-btn,
+.chip-toggle,
+.toggle-chip,
+.studio-reset-btn,
+.studio-bulk-btn,
+.icon-btn {
+ border-radius: 10px !important;
+}
+
+.search-input,
+.ctrl-input,
+.ctrl-select,
+.ctrl-textarea,
+.color-wrap input[type="text"],
+.color-wrap input[type="color"],
+.picker-row input[type="text"],
+.picker-row input[type="color"],
+.asset-btn,
+.slider-step,
+.btn,
+.pager-btn,
+.tab-btn,
+.toggle-chip,
+.chip-toggle,
+.studio-reset-btn,
+.icon-btn {
+ background: var(--surface2) !important;
+ border: 1px solid var(--border) !important;
+ color: var(--text) !important;
+}
+
+.search-input::placeholder,
+.ctrl-input::placeholder,
+.ctrl-textarea::placeholder { color: var(--text-3) !important; }
+
+.search-input:focus,
+.ctrl-input:focus,
+.ctrl-select:focus,
+.ctrl-textarea:focus,
+.color-wrap input[type="text"]:focus,
+.picker-row input[type="text"]:focus {
+ border-color: var(--border-active) !important;
+ box-shadow: 0 0 0 3px var(--accent-glow) !important;
+ outline: none;
+}
+
+.search-inner svg { color: var(--text-3); }
+
+.btn,
+.pager-btn,
+.tab-btn,
+.seg-btn,
+.slider-step,
+.asset-btn,
+.toggle-chip,
+.chip-toggle,
+.studio-bulk-btn,
+.studio-reset-btn,
+.icon-btn {
+ transition:
+ background 160ms var(--ease-out),
+ border-color 160ms var(--ease-out),
+ color 160ms var(--ease-out),
+ transform 160ms var(--ease-out),
+ opacity 160ms var(--ease-out) !important;
+}
+
+.btn:hover:not(:disabled),
+.pager-btn:hover:not(:disabled),
+.tab-btn:hover:not(:disabled),
+.slider-step:hover:not(:disabled),
+.asset-btn:hover:not(:disabled),
+.toggle-chip:hover:not(:disabled),
+.chip-toggle:hover:not(:disabled),
+.icon-btn:hover:not(:disabled) {
+ background: var(--surface3) !important;
+ border-color: var(--border-strong) !important;
+}
+
+.btn:active:not(:disabled),
+.pager-btn:active:not(:disabled) { transform: translateY(1px); }
+
+.btn-primary,
+.studio-bulk-btn {
+ background: var(--accent) !important;
+ border-color: rgba(54, 214, 224, 0.4) !important;
+ color: #04181b !important;
+ font-weight: 600;
+}
+
+.btn-primary:hover:not(:disabled),
+.studio-bulk-btn:hover:not(:disabled) {
+ background: var(--accent-h) !important;
+ border-color: rgba(94, 231, 239, 0.5) !important;
+}
+
+.btn-green {
+ background: var(--green-bg) !important;
+ border-color: var(--green-bd) !important;
+ color: #b7f0d4 !important;
+}
+
+.btn-green:hover:not(:disabled) { background: rgba(70, 217, 154, 0.22) !important; }
+
+/* Active / selected states across pickers */
+.tab-btn.active,
+.toggle-chip.active,
+.pos-btn.active,
+.corner-btn.active,
+.studio-btn.active,
+.result-item.active {
+ background: var(--accent-soft) !important;
+ border-color: var(--border-active) !important;
+ color: var(--text) !important;
+}
+
+.result-item:hover,
+.asset-card:hover { background: var(--surface2) !important; }
+
+.result-poster { background: var(--surface3) !important; }
+
+.results::-webkit-scrollbar-thumb { background: var(--border-strong); }
+
+/* ============================================================================
+ Tables (Tracearr history/users vocabulary)
+ ========================================================================== */
+
+.data-table { width: 100%; border-collapse: collapse; font-size: 13px; }
+
+.data-table thead th {
+ text-align: left;
+ padding: 11px 14px;
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--text-3);
+ border-bottom: 1px solid var(--border);
+ white-space: nowrap;
+}
+
+.data-table tbody td {
+ padding: 12px 14px;
+ border-bottom: 1px solid var(--border);
+ color: var(--text-2);
+ vertical-align: middle;
+}
+
+.data-table tbody tr { transition: background 140ms var(--ease-out); }
+.data-table tbody tr:hover { background: rgba(54, 214, 224, 0.04); }
+.data-table tbody tr:last-child td { border-bottom: 0; }
+.data-table .cell-strong { color: var(--text); font-weight: 600; }
+.data-table .cell-sub { color: var(--text-3); font-size: 12px; }
+.data-table .cell-num { color: var(--text); font-variant-numeric: tabular-nums; }
+
+/* Avatars */
+.avatar {
+ width: 30px;
+ height: 30px;
+ border-radius: 50%;
+ display: grid;
+ place-items: center;
+ font-size: 11px;
+ font-weight: 700;
+ color: #04181b;
+ background: linear-gradient(155deg, #5ee7ef, #2bb6c4);
+ flex-shrink: 0;
+}
+
+/* Status badges */
+.badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ padding: 2px 9px;
+ border-radius: 999px;
+ font-size: 11px;
+ font-weight: 600;
+ letter-spacing: 0.02em;
+ border: 1px solid var(--border);
+ background: var(--surface3);
+ color: var(--text-2);
+}
+
+.badge-ok,
+.badge-watched,
+.badge-direct,
+.badge-trusted {
+ background: var(--green-bg);
+ border-color: var(--green-bd);
+ color: #b7f0d4;
+}
+
+.badge-warn,
+.badge-sampled {
+ background: var(--amber-bg);
+ border-color: var(--amber-bd);
+ color: #f6d98c;
+}
+
+.badge-bad,
+.badge-abandoned {
+ background: var(--red-bg);
+ border-color: var(--red-bd);
+ color: #ffb4b2;
+}
+
+/* Trust pill */
+.trust-pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 3px 10px;
+ border-radius: 999px;
+ font-size: 11px;
+ font-weight: 600;
+ background: var(--green-bg);
+ border: 1px solid var(--green-bd);
+ color: #b7f0d4;
+}
+
+/* Thin progress bar */
+.bar {
+ width: 84px;
+ height: 5px;
+ border-radius: 99px;
+ background: var(--surface4);
+ overflow: hidden;
+}
+
+.bar-fill {
+ height: 100%;
+ border-radius: 99px;
+ background: linear-gradient(90deg, var(--accent-2), var(--accent));
+}
+
+/* Generic chips / tags / pills used by the result lists */
+.result-sub .tag,
+.tag,
+.meta-chip,
+.pill,
+.asset-pill {
+ letter-spacing: 0.03em !important;
+}
+
+.result-sub .tag,
+.tag,
+.meta-chip,
+.pill-muted,
+.asset-pill.off {
+ background: var(--surface3) !important;
+ border-color: var(--border) !important;
+ color: var(--text-2) !important;
+}
+
+.pill-green,
+.meta-chip.ok,
+.asset-pill {
+ background: var(--green-bg) !important;
+ border-color: var(--green-bd) !important;
+ color: #b7f0d4 !important;
+}
+
+/* ============================================================================
+ Sliders + spinner accent
+ ========================================================================== */
+
+input[type="range"] { accent-color: var(--accent); }
+.spinner { border-color: var(--border) !important; border-top-color: var(--accent) !important; }
+
+/* ============================================================================
+ Toast
+ ========================================================================== */
+
+.toast { border-radius: 12px !important; box-shadow: var(--shadow-soft); }
+
+.toast.ok {
+ background: var(--green-bg) !important;
+ border: 1px solid var(--green-bd) !important;
+ color: #b7f0d4 !important;
+}
+
+.toast.err {
+ background: var(--red-bg) !important;
+ border: 1px solid var(--red-bd) !important;
+ color: #ffb4b2 !important;
+}
diff --git a/templates/airing.html b/templates/airing.html
index 4777744..288f256 100644
--- a/templates/airing.html
+++ b/templates/airing.html
@@ -4,8 +4,7 @@
EmbyToolkit
-
-
+
+
+
+
+
+
+
EmbyToolkit
+
+
+
+
+
+
+
+
+
+
+
+
User Favourites
+
Browse any Emby collection and view it as a given user. For a user's own "{Name} Favorites" 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.
+
+
+
+
+
Collection
+
+
Loading collections…
+
+
+
+
+
Watched status for
+
+
+
+
+
+
+
+
+
—Current collection count
+
+
+
+
—Watched items found
+
+
+
+
—Items recommended
+
+
+
+
—Final count after apply
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Collection
+
+
+
+
+
+
+
Title
+
Type
+
Year
+
Runtime
+
Watched
+
Emby item id
+
+
+
+
+
+
This collection has no items.
+
+
+
+
+
+
+ No collections found. Create a collection named "{User} Favorites" in Emby (for example "Matt Favorites") and refresh.
+