Homelabtoolkit v2

This commit is contained in:
2026-06-08 21:58:16 +12:00
parent 040fbacc70
commit 3c77066beb
75 changed files with 16945 additions and 374 deletions
+65 -9
View File
@@ -1,34 +1,53 @@
import { useEffect, useState } from "react";
import { Navigate, Route, Routes, useLocation } from "react-router-dom";
import Sidebar from "./components/Sidebar";
import CommandPalette from "./components/CommandPalette";
import { AppConfig, apiGet } from "./api";
import { IconClose, IconMenu } from "./components/icons";
import Dashboard from "./pages/Dashboard";
import Generator from "./pages/emby/Generator";
import AvatarGenerator from "./pages/emby/AvatarGenerator";
import Collections from "./pages/emby/Collections";
import Airing from "./pages/emby/Airing";
import BulkAssign from "./pages/emby/BulkAssign";
import Favorites from "./pages/emby/Favorites";
import HomescreenEditor from "./pages/emby/HomescreenEditor";
import Library from "./pages/navidrome/Library";
import Reporting from "./pages/navidrome/Reporting";
import CoverManager from "./pages/navidrome/CoverManager";
import Metadata from "./pages/navidrome/Metadata";
import CollectionCompleteness from "./pages/navidrome/CollectionCompleteness";
import Settings from "./pages/Settings";
import AudiobookshelfOverview from "./pages/audiobookshelf/Overview";
import Tasks from "./pages/Tasks";
const CRUMBS: Record<string, [string, string]> = {
"/": ["", "Dashboard"],
"/emby/generator": ["Emby", "Thumbnail Generator"],
"/emby/collections": ["Emby", "Collection Art"],
"/emby/airing": ["Emby", "Airing & New Seasons"],
"/emby/bulk-assign": ["Emby", "Bulk Assign"],
"/emby/favorites": ["Emby", "User Favorites"],
"/emby/generator": ["Emby", "Thumb Studio"],
"/emby/avatar-generator": ["Emby", "Avatars"],
"/emby/collections": ["Emby", "Collection Covers"],
"/emby/airing": ["Emby", "Airing Calendar"],
"/emby/bulk-assign": ["Emby", "Batch Artwork"],
"/emby/favorites": ["Emby", "Favorites"],
"/emby/homescreen": ["Emby", "Home Screen"],
"/navidrome/library": ["Navidrome", "Music Library"],
"/navidrome/covers": ["Navidrome", "Cover Manager"],
"/navidrome/reporting": ["Navidrome", "Reporting"],
"/navidrome/cleanup": ["Navidrome", "Library Cleanup"],
"/navidrome/metadata": ["Navidrome", "Metadata Editor"],
"/collection-completeness": ["Navidrome", "Collection Completeness"],
"/audiobookshelf": ["Audiobookshelf", "Overview"],
"/tasks": ["System", "Tasks"],
"/settings": ["System", "Settings"],
};
export default function App() {
const [config, setConfig] = useState<AppConfig | null>(null);
const [navidromeConnected, setNavidromeConnected] = useState(false);
const [audiobookshelfConnected, setAudiobookshelfConnected] = useState(false);
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const [compactShell, setCompactShell] = useState(() =>
typeof window !== "undefined" ? window.innerWidth <= 1280 : false
);
const location = useLocation();
function refreshConfig() {
@@ -38,17 +57,47 @@ export default function App() {
apiGet<{ connected: boolean }>("/api/navidrome/status")
.then((s) => setNavidromeConnected(!!s.connected))
.catch(() => setNavidromeConnected(false));
apiGet<{ connected: boolean }>("/api/audiobookshelf/status")
.then((s) => setAudiobookshelfConnected(!!s.connected))
.catch(() => setAudiobookshelfConnected(false));
}
useEffect(refreshConfig, []);
useEffect(() => {
const sync = () => {
const compact = window.innerWidth <= 1280;
setCompactShell(compact);
if (!compact) setMobileNavOpen(false);
};
sync();
window.addEventListener("resize", sync);
return () => window.removeEventListener("resize", sync);
}, []);
const [section, page] = CRUMBS[location.pathname] || ["", ""];
return (
<div className="app">
<Sidebar config={config} navidromeConnected={navidromeConnected} />
<div className={`app ${compactShell ? "app-compact" : ""}`}>
<Sidebar
config={config}
navidromeConnected={navidromeConnected}
audiobookshelfConnected={audiobookshelfConnected}
mobileOpen={compactShell && mobileNavOpen}
onClose={() => setMobileNavOpen(false)}
/>
<div className="main">
<header className="topbar">
<button
className="btn btn-sm topbar-menu"
onClick={() => setMobileNavOpen((open) => !open)}
aria-label={mobileNavOpen ? "Close navigation" : "Open navigation"}
aria-expanded={mobileNavOpen}
aria-hidden={!compactShell}
tabIndex={compactShell ? 0 : -1}
>
{mobileNavOpen ? <IconClose /> : <IconMenu />}
</button>
<div className="crumbs">
{section && (
<>
@@ -58,18 +107,25 @@ export default function App() {
{page}
</div>
<div className="topbar-spacer" />
<CommandPalette />
</header>
<div className="content">
<Routes>
<Route path="/" element={<Dashboard config={config} navidromeConnected={navidromeConnected} />} />
<Route path="/emby/generator" element={<Generator />} />
<Route path="/emby/avatar-generator" element={<AvatarGenerator />} />
<Route path="/emby/collections" element={<Collections />} />
<Route path="/emby/airing" element={<Airing />} />
<Route path="/emby/bulk-assign" element={<BulkAssign />} />
<Route path="/emby/favorites" element={<Favorites />} />
<Route path="/emby/homescreen" element={<HomescreenEditor />} />
<Route path="/navidrome/library" element={<Library />} />
<Route path="/navidrome/covers" element={<CoverManager />} />
<Route path="/navidrome/reporting" element={<Reporting />} />
<Route path="/navidrome/cleanup" element={<CoverManager />} />
<Route path="/navidrome/metadata" element={<Metadata />} />
<Route path="/collection-completeness" element={<CollectionCompleteness />} />
<Route path="/audiobookshelf" element={<AudiobookshelfOverview />} />
<Route path="/tasks" element={<Tasks />} />
<Route path="/settings" element={<Settings onSaved={refreshConfig} />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
+54
View File
@@ -51,6 +51,49 @@ export async function apiPostImage(
return { url: URL.createObjectURL(blob), cacheKey: res.headers.get("X-Cache-Key") };
}
// Reads a newline-delimited JSON stream, invoking onMessage per parsed object.
// Used for the disk-efficient music scan and the live maintenance run.
export async function streamNDJSON(
path: string,
opts: { method?: string; body?: unknown; signal?: AbortSignal; onMessage: (obj: any) => void }
): Promise<void> {
const res = await fetch(path, {
method: opts.method || "GET",
headers: opts.body !== undefined ? { "Content-Type": "application/json" } : undefined,
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
signal: opts.signal,
});
if (!res.ok || !res.body) throw new ApiError(await parseError(res), res.status);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
let nl: number;
while ((nl = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, nl).trim();
buf = buf.slice(nl + 1);
if (line) {
try {
opts.onMessage(JSON.parse(line));
} catch {
/* ignore partial/invalid line */
}
}
}
}
const tail = buf.trim();
if (tail) {
try {
opts.onMessage(JSON.parse(tail));
} catch {
/* ignore */
}
}
}
export async function uploadBackground(file: File): Promise<{ upload_id: string; width: number; height: number }> {
const form = new FormData();
form.append("file", file);
@@ -59,11 +102,22 @@ export async function uploadBackground(file: File): Promise<{ upload_id: string;
return res.json();
}
export async function uploadHomescreenDb(
file: File
): Promise<{ upload: { upload_id: string; filename: string; size_bytes: number; uploaded_at: string; sha256: string; path: string } }> {
const form = new FormData();
form.append("file", file);
const res = await fetch("/api/homescreen/db-upload", { method: "POST", body: form });
if (!res.ok) throw new ApiError(await parseError(res), res.status);
return res.json();
}
// ── Shared types ────────────────────────────────────────────────────────────
export interface AppConfig {
app_name: string;
emby: { url: string; connected: boolean };
navidrome: { url: string; configured: boolean };
audiobookshelf: { url: string; configured: boolean };
music: { root: string; available: boolean };
}
+111
View File
@@ -0,0 +1,111 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { COMMANDS } from "../lib/commands";
import { IconSearch } from "./icons";
export default function CommandPalette() {
const navigate = useNavigate();
const [query, setQuery] = useState("");
const [open, setOpen] = useState(false);
const [active, setActive] = useState(0);
const rootRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const results = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return COMMANDS;
return COMMANDS.filter((c) => `${c.label} ${c.section} ${c.keywords || ""}`.toLowerCase().includes(q));
}, [query]);
// Keep the active row in range as the result set changes.
useEffect(() => {
setActive(0);
}, [query]);
// Global Ctrl/⌘K to focus, Esc handled on the input.
useEffect(() => {
function onKey(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
inputRef.current?.focus();
setOpen(true);
}
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
// Close when clicking outside.
useEffect(() => {
function onClick(e: MouseEvent) {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
}
document.addEventListener("mousedown", onClick);
return () => document.removeEventListener("mousedown", onClick);
}, []);
function go(to: string) {
navigate(to);
setOpen(false);
setQuery("");
inputRef.current?.blur();
}
function onKeyDown(e: React.KeyboardEvent) {
if (e.key === "ArrowDown") {
e.preventDefault();
setActive((a) => Math.min(a + 1, results.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActive((a) => Math.max(a - 1, 0));
} else if (e.key === "Enter") {
e.preventDefault();
if (results[active]) go(results[active].to);
} else if (e.key === "Escape") {
setOpen(false);
inputRef.current?.blur();
}
}
return (
<div className="cmdk" ref={rootRef}>
<div className="cmdk-field">
<IconSearch />
<input
ref={inputRef}
className="cmdk-input"
placeholder="Search commands…"
value={query}
onFocus={() => setOpen(true)}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onKeyDown}
/>
<kbd className="cmdk-kbd">K</kbd>
</div>
{open && (
<div className="cmdk-menu">
{results.length === 0 ? (
<div className="cmdk-empty">No matching commands</div>
) : (
results.map((c, i) => (
<button
key={c.to}
className={`cmdk-item ${i === active ? "active" : ""}`}
onMouseEnter={() => setActive(i)}
onMouseDown={(e) => {
e.preventDefault(); // keep focus so blur doesn't close before click
go(c.to);
}}
>
<span className="cmdk-icon">{c.icon}</span>
<span className="cmdk-label">{c.label}</span>
<span className="cmdk-section">{c.section}</span>
</button>
))
)}
</div>
)}
</div>
);
}
+45 -23
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { NavLink, useLocation } from "react-router-dom";
import { AppConfig } from "../api";
import {
IconBook,
IconCalendar,
IconChevron,
IconDisc,
@@ -13,6 +14,8 @@ import {
IconLayers,
IconMusic,
IconSettings,
IconTrash,
IconUser,
IconWand,
} from "./icons";
@@ -28,11 +31,13 @@ const GROUPS: { id: string; label: string; icon: JSX.Element; links: NavItem[] }
label: "Emby",
icon: <IconEmby />,
links: [
{ to: "/emby/generator", label: "Thumbnail Generator", icon: <IconImage /> },
{ to: "/emby/collections", label: "Collection Art", icon: <IconLayers /> },
{ to: "/emby/airing", label: "Airing & New Seasons", icon: <IconCalendar /> },
{ to: "/emby/bulk-assign", label: "Bulk Assign", icon: <IconGrid /> },
{ to: "/emby/favorites", label: "User Favorites", icon: <IconHeart /> },
{ to: "/emby/generator", label: "Thumb Studio", icon: <IconImage /> },
{ to: "/emby/avatar-generator", label: "Avatars", icon: <IconUser /> },
{ to: "/emby/collections", label: "Collection Covers", icon: <IconLayers /> },
{ to: "/emby/airing", label: "Airing Calendar", icon: <IconCalendar /> },
{ to: "/emby/bulk-assign", label: "Batch Artwork", icon: <IconGrid /> },
{ to: "/emby/favorites", label: "Favorites", icon: <IconHeart /> },
{ to: "/emby/homescreen", label: "Home Screen", icon: <IconLayers /> },
],
},
{
@@ -41,15 +46,17 @@ const GROUPS: { id: string; label: string; icon: JSX.Element; links: NavItem[] }
icon: <IconMusic />,
links: [
{ to: "/navidrome/library", label: "Music Library", icon: <IconDisc /> },
{ to: "/navidrome/covers", label: "Cover Manager", icon: <IconWand /> },
{ to: "/navidrome/reporting", label: "Reporting", icon: <IconMusic /> },
{ to: "/navidrome/cleanup", label: "Library Cleanup", icon: <IconWand /> },
{ to: "/navidrome/metadata", label: "Metadata Editor", icon: <IconMusic /> },
{ to: "/collection-completeness", label: "Collection Completeness", icon: <IconLayers /> },
],
},
{
id: "system",
label: "System",
icon: <IconSettings />,
links: [{ to: "/settings", label: "Settings", icon: <IconSettings /> }],
id: "audiobookshelf",
label: "Audiobookshelf",
icon: <IconBook />,
links: [{ to: "/audiobookshelf", label: "Overview", icon: <IconBook /> }],
},
];
@@ -70,29 +77,35 @@ function Chip({ label, ok, configured }: { label: string; ok: boolean; configure
interface Props {
config: AppConfig | null;
navidromeConnected: boolean;
audiobookshelfConnected: boolean;
mobileOpen: boolean;
onClose: () => void;
}
export default function Sidebar({ config, navidromeConnected }: Props) {
export default function Sidebar({ config, navidromeConnected, audiobookshelfConnected, mobileOpen, onClose }: Props) {
const location = useLocation();
const activeGroup = GROUPS.find((g) => g.links.some((l) => location.pathname.startsWith(l.to)))?.id;
// Categories start collapsed; the group holding the current route opens itself.
const [open, setOpen] = useState<Record<string, boolean>>(() => (activeGroup ? { [activeGroup]: true } : {}));
// Accordion: only one category open at a time. The active route's group opens.
const [open, setOpen] = useState<string | null>(activeGroup ?? null);
useEffect(() => {
if (activeGroup) setOpen((o) => (o[activeGroup] ? o : { ...o, [activeGroup]: true }));
if (activeGroup) setOpen(activeGroup);
}, [activeGroup]);
const toggle = (id: string) => setOpen((o) => ({ ...o, [id]: !o[id] }));
useEffect(() => {
onClose();
}, [location.pathname]); // eslint-disable-line react-hooks/exhaustive-deps
const toggle = (id: string) => setOpen((cur) => (cur === id ? null : id));
return (
<nav className="nav">
<>
<button className={`nav-backdrop ${mobileOpen ? "open" : ""}`} onClick={onClose} aria-label="Close navigation" />
<nav className={`nav ${mobileOpen ? "mobile-open" : ""}`}>
<div className="nav-brand">
<div className="nav-logo">H</div>
<div className="nav-name">
HomelabToolkit
<small>media operations</small>
</div>
<div className="nav-name">HomelabToolkit</div>
</div>
<div className="nav-scroll">
@@ -104,7 +117,7 @@ export default function Sidebar({ config, navidromeConnected }: Props) {
</div>
{GROUPS.map((g) => {
const isOpen = !!open[g.id];
const isOpen = open === g.id;
return (
<div className="nav-group" key={g.id}>
<button className="nav-group-head" onClick={() => toggle(g.id)} aria-expanded={isOpen}>
@@ -134,10 +147,19 @@ export default function Sidebar({ config, navidromeConnected }: Props) {
</div>
<div className="nav-foot">
<NavLink to="/tasks" className={({ isActive }) => `nav-item nav-item-top ${isActive ? "active" : ""}`}>
<IconTrash />
Tasks
</NavLink>
<NavLink to="/settings" className={({ isActive }) => `nav-item nav-item-top ${isActive ? "active" : ""}`}>
<IconSettings />
Settings
</NavLink>
<Chip label="Emby" ok={!!config?.emby.connected} configured={!!config?.emby.connected} />
<Chip label="Navidrome" ok={navidromeConnected} configured={!!config?.navidrome.configured} />
<div className="nav-version">HomelabToolkit v1.0</div>
<Chip label="Audiobookshelf" ok={audiobookshelfConnected} configured={!!config?.audiobookshelf.configured} />
</div>
</nav>
</nav>
</>
);
}
+50
View File
@@ -115,6 +115,26 @@ export const IconUser = (p: P) => (
<path d="M4 21v-1a6 6 0 0 1 6-6h4a6 6 0 0 1 6 6v1" />
</svg>
);
export const IconApple = (p: P) => (
<svg {...base} {...p}>
<path d="M15.3 12.2c0-1.7 1.4-2.6 1.5-2.7-.8-1.2-2.1-1.4-2.6-1.4-1.1-.1-2.1.6-2.7.6-.6 0-1.4-.6-2.3-.6-1.2 0-2.3.7-2.9 1.7-1.3 2.2-.3 5.6.9 7.3.6.8 1.3 1.8 2.2 1.7.9 0 1.2-.6 2.3-.6 1 0 1.4.6 2.3.6 1 0 1.6-.8 2.2-1.7.7-1 1-2 1-2.1-.1 0-1.9-.7-1.9-2.8z" />
<path d="M13.7 6.8c.5-.6.9-1.5.8-2.3-.8 0-1.7.5-2.2 1.1-.5.6-.9 1.4-.8 2.2.9.1 1.7-.4 2.2-1z" />
</svg>
);
export const IconAndroid = (p: P) => (
<svg {...base} {...p}>
<path d="M8 9l-1.8-2.7M16 9l1.8-2.7M9 5.5l.2-.1a7 7 0 0 1 5.6 0l.2.1" />
<rect x="6" y="9" width="12" height="8" rx="2.5" />
<path d="M8.5 17v2.5M15.5 17v2.5M4.5 10.5V15M19.5 10.5V15" />
<path d="M10 12h.01M14 12h.01" />
</svg>
);
export const IconWeb = (p: P) => (
<svg {...base} {...p}>
<circle cx="12" cy="12" r="9" />
<path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" />
</svg>
);
export const IconChevron = (p: P) => (
<svg {...base} {...p}>
<path d="M9 18l6-6-6-6" />
@@ -125,12 +145,42 @@ export const IconFolder = (p: P) => (
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
</svg>
);
export const IconBook = (p: P) => (
<svg {...base} {...p}>
<path d="M4 4a2 2 0 0 1 2-2h13v18H6a2 2 0 0 0-2 2z" />
<path d="M4 20a2 2 0 0 0 2 2h13" />
<path d="M9 7h6" />
</svg>
);
export const IconHeadphones = (p: P) => (
<svg {...base} {...p}>
<path d="M4 14v-2a8 8 0 0 1 16 0v2" />
<rect x="2.5" y="14" width="4.5" height="6" rx="1.5" />
<rect x="17" y="14" width="4.5" height="6" rx="1.5" />
</svg>
);
export const IconClock = (p: P) => (
<svg {...base} {...p}>
<circle cx="12" cy="12" r="9" />
<path d="M12 7v5l3 2" />
</svg>
);
export const IconSettings = (p: P) => (
<svg {...base} {...p}>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
);
export const IconMenu = (p: P) => (
<svg {...base} {...p}>
<path d="M4 7h16M4 12h16M4 17h16" />
</svg>
);
export const IconClose = (p: P) => (
<svg {...base} {...p}>
<path d="M6 6l12 12M18 6L6 18" />
</svg>
);
// Stylized Emby media mark (rounded square + play). Inherits currentColor so it
// tints with nav state; swap in the official asset if you have it.
export const IconEmby = (p: P) => (
+3 -6
View File
@@ -1,13 +1,10 @@
import { ReactNode } from "react";
export function PageHead({ title, icon, children }: { title: string; icon?: ReactNode; children?: ReactNode }) {
export function PageHead({ title, icon }: { title: string; icon?: ReactNode }) {
return (
<div className="page-head">
<div className="page-head-row">
{icon && <span className="page-head-icon">{icon}</span>}
<h1>{title}</h1>
</div>
{children && <p>{children}</p>}
{icon && <span className="page-head-icon">{icon}</span>}
<h1>{title}</h1>
</div>
);
}
+47
View File
@@ -0,0 +1,47 @@
import {
IconBook,
IconCalendar,
IconDisc,
IconGrid,
IconHeart,
IconHome,
IconImage,
IconLayers,
IconMusic,
IconSettings,
IconTrash,
IconUser,
IconWand,
} from "../components/icons";
export interface Command {
to: string;
label: string;
section: string;
icon: JSX.Element;
keywords?: string;
}
/** Every navigable destination in the app — drives the command palette. */
export const COMMANDS: Command[] = [
{ to: "/", label: "Dashboard", section: "Home", icon: <IconHome />, keywords: "overview stats home" },
{ to: "/emby/generator", label: "Thumb Studio", section: "Emby", icon: <IconImage />, keywords: "thumbnail thumb cover artwork poster" },
{ to: "/emby/avatar-generator", label: "Avatars", section: "Emby", icon: <IconUser />, keywords: "avatar users profile initials python script" },
{ to: "/emby/collections", label: "Collection Covers", section: "Emby", icon: <IconLayers />, keywords: "collection art covers artwork" },
{ to: "/emby/airing", label: "Airing Calendar", section: "Emby", icon: <IconCalendar />, keywords: "airing schedule new season calendar" },
{ to: "/emby/bulk-assign", label: "Batch Artwork", section: "Emby", icon: <IconGrid />, keywords: "bulk batch assign artwork" },
{ to: "/emby/favorites", label: "Favorites", section: "Emby", icon: <IconHeart />, keywords: "favourites favorites users" },
{ to: "/emby/homescreen", label: "Home Screen", section: "Emby", icon: <IconLayers />, keywords: "home screen homescreen editor users db sections emby" },
{ to: "/navidrome/library", label: "Music Library", section: "Navidrome", icon: <IconDisc />, keywords: "music albums artists" },
{ to: "/navidrome/reporting", label: "Reporting", section: "Navidrome", icon: <IconMusic />, keywords: "plays top tracks stats reports navidrome" },
{ to: "/navidrome/cleanup", label: "Library Cleanup", section: "Navidrome", icon: <IconWand />, keywords: "clean rename covers lyrics" },
{ to: "/navidrome/metadata", label: "Metadata Editor", section: "Navidrome", icon: <IconMusic />, keywords: "genre tags junk track number musicbrainz" },
{ to: "/collection-completeness", label: "Collection Completeness", section: "Navidrome", icon: <IconLayers />, keywords: "missing albums discography" },
{ to: "/audiobookshelf", label: "Overview", section: "Audiobookshelf", icon: <IconBook />, keywords: "audiobooks abs" },
{ to: "/tasks", label: "Tasks", section: "System", icon: <IconTrash />, keywords: "automation cleanup maintenance scheduler" },
{ to: "/settings", label: "Settings", section: "System", icon: <IconSettings />, keywords: "config emby navidrome url api key" },
];
+181 -74
View File
@@ -4,6 +4,8 @@ import { AppConfig, apiGet, apiPost } from "../api";
import { useToast } from "../lib/toast";
import { PageHead, StatCard, Loading, Empty, Avatar, timeAgo, fmtNumber, formatNZ } from "../components/ui";
import {
IconAndroid,
IconApple,
IconCalendar,
IconChevron,
IconDisc,
@@ -18,6 +20,7 @@ import {
IconRefresh,
IconUser,
IconWand,
IconWeb,
} from "../components/icons";
interface Props {
@@ -71,6 +74,32 @@ interface FormatData {
formats: { format: string; count: number }[];
}
const FORMATS_SESSION_KEY = "dashboard.navidrome.formats";
function readCachedFormats(): FormatData | null {
try {
const raw = window.sessionStorage.getItem(FORMATS_SESSION_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.formats) || typeof parsed.total !== "number") return null;
return parsed as FormatData;
} catch {
return null;
}
}
function writeCachedFormats(data: FormatData | null) {
try {
if (!data) {
window.sessionStorage.removeItem(FORMATS_SESSION_KEY);
return;
}
window.sessionStorage.setItem(FORMATS_SESSION_KEY, JSON.stringify(data));
} catch {
// Ignore browser storage failures; the in-memory state still works.
}
}
const FORMAT_COLORS: Record<string, string> = {
flac: "var(--accent)",
mp3: "var(--amber)",
@@ -86,13 +115,16 @@ const FORMAT_COLORS: Record<string, string> = {
const formatColor = (fmt: string) => FORMAT_COLORS[fmt.toLowerCase()] || "#6b7c90";
const tools = [
{ to: "/emby/generator", label: "Thumbnail Generator", icon: <IconImage />, cat: "Emby" },
{ to: "/emby/collections", label: "Collection Art", icon: <IconLayers />, cat: "Emby" },
{ to: "/emby/airing", label: "Airing & New Seasons", icon: <IconCalendar />, cat: "Emby" },
{ to: "/emby/bulk-assign", label: "Bulk Assign", icon: <IconGrid />, cat: "Emby" },
{ to: "/emby/favorites", label: "User Favorites", icon: <IconHeart />, cat: "Emby" },
{ to: "/emby/generator", label: "Thumb Studio", icon: <IconImage />, cat: "Emby" },
{ to: "/emby/avatar-generator", label: "Avatars", icon: <IconUser />, cat: "Emby" },
{ to: "/emby/collections", label: "Collection Covers", icon: <IconLayers />, cat: "Emby" },
{ to: "/emby/airing", label: "Airing Calendar", icon: <IconCalendar />, cat: "Emby" },
{ to: "/emby/bulk-assign", label: "Batch Artwork", icon: <IconGrid />, cat: "Emby" },
{ to: "/emby/favorites", label: "Favorites", icon: <IconHeart />, cat: "Emby" },
{ to: "/emby/homescreen", label: "Home Screen", icon: <IconLayers />, cat: "Emby" },
{ to: "/navidrome/library", label: "Music Library", icon: <IconDisc />, cat: "Navidrome" },
{ to: "/navidrome/covers", label: "Cover Manager", icon: <IconWand />, cat: "Navidrome" },
{ to: "/navidrome/reporting", label: "Reporting", icon: <IconMusic />, cat: "Navidrome" },
{ to: "/navidrome/cleanup", label: "Library Cleanup", icon: <IconWand />, cat: "Navidrome" },
];
function MiniStat({ icon, value, label }: { icon: ReactNode; value: ReactNode; label: string }) {
@@ -112,12 +144,52 @@ function StatusBadge({ configured, connected }: { configured: boolean; connected
return <span className={`badge ${connected ? "badge-ok" : "badge-bad"}`}>{connected ? "connected" : "offline"}</span>;
}
type DevicePlatform = "apple" | "android" | "web" | "other";
function detectPlatform(user: UserActivity): DevicePlatform {
const haystack = [user.device, user.client].filter(Boolean).join(" ").toLowerCase();
if (/(iphone|ipad|ipod|apple tv|appletv|ios|tvos|mac|macos|safari)/.test(haystack)) return "apple";
if (/(android|google tv|shield|fire tv|firetv|chromecast)/.test(haystack)) return "android";
if (/(web|chrome|firefox|edge|browser|opera)/.test(haystack)) return "web";
return "other";
}
function platformLabel(platform: DevicePlatform) {
if (platform === "apple") return "Apple";
if (platform === "android") return "Android";
if (platform === "web") return "Web";
return "Other";
}
function platformIcon(platform: DevicePlatform) {
if (platform === "apple") return <IconApple />;
if (platform === "android") return <IconAndroid />;
if (platform === "web") return <IconWeb />;
return <IconUser />;
}
function platformBadgeClass(platform: DevicePlatform) {
if (platform === "apple") return "activity-device-chip apple";
if (platform === "android") return "activity-device-chip android";
if (platform === "web") return "activity-device-chip web";
return "activity-device-chip";
}
function platformSummaryItems(summary: ActivitySummary) {
return [
{ key: "apple", label: "Apple", count: summary.platforms.ios, pct: summary.platform_pct.ios, icon: <IconApple /> },
{ key: "android", label: "Android", count: summary.platforms.android, pct: summary.platform_pct.android, icon: <IconAndroid /> },
{ key: "web", label: "Web", count: summary.platforms.web, pct: summary.platform_pct.web, icon: <IconWeb /> },
{ key: "other", label: "Other", count: summary.platforms.other, pct: summary.platform_pct.other, icon: <IconUser /> },
].filter((item) => item.count > 0);
}
export default function Dashboard({ config, navidromeConnected }: Props) {
const toast = useToast();
const [data, setData] = useState<DashboardData | null>(null);
const [activity, setActivity] = useState<UserActivity[] | null>(null);
const [activitySummary, setActivitySummary] = useState<ActivitySummary | null>(null);
const [formats, setFormats] = useState<FormatData | null>(null);
const [formats, setFormats] = useState<FormatData | null>(() => readCachedFormats());
const [formatsLoading, setFormatsLoading] = useState(false);
const [loading, setLoading] = useState(true);
const [embyScanning, setEmbyScanning] = useState(false);
@@ -147,7 +219,28 @@ export default function Dashboard({ config, navidromeConnected }: Props) {
}
}
function load() {
// Format breakdown pages the whole song list, so it's cached server-side for the
// session. A normal page load reuses that cache; pass force to rescan on demand.
function loadFormats(force = false) {
if (!force) {
const cached = readCachedFormats();
if (cached) {
setFormats(cached);
setFormatsLoading(false);
return;
}
}
setFormatsLoading(true);
apiGet<FormatData>(`/api/navidrome/formats${force ? "?refresh=true" : ""}`)
.then((result) => {
setFormats(result);
writeCachedFormats(result);
})
.catch(() => setFormats((current) => current ?? null))
.finally(() => setFormatsLoading(false));
}
function load(force = false) {
setLoading(true);
apiGet<DashboardData>("/api/dashboard")
.then(setData)
@@ -162,15 +255,11 @@ export default function Dashboard({ config, navidromeConnected }: Props) {
setActivity([]);
setActivitySummary(null);
});
// Format breakdown pages the whole song list, so it may take a moment on the
// first load; the backend caches it for subsequent calls.
setFormatsLoading(true);
apiGet<FormatData>("/api/navidrome/formats")
.then(setFormats)
.catch(() => setFormats(null))
.finally(() => setFormatsLoading(false));
loadFormats(force);
}
useEffect(load, []);
useEffect(() => {
load();
}, []);
const e = data?.emby;
const n = data?.navidrome;
@@ -180,11 +269,8 @@ export default function Dashboard({ config, navidromeConnected }: Props) {
return (
<>
<div className="row between" style={{ alignItems: "flex-start" }}>
<PageHead title="Dashboard" icon={<IconHome />}>
A live overview of your media stack Emby library health on the left, your Navidrome music collection on the
right.
</PageHead>
<button className="btn btn-sm" onClick={load} disabled={loading}>
<PageHead title="Dashboard" icon={<IconHome />} />
<button className="btn btn-sm" onClick={() => load()} disabled={loading || formatsLoading}>
{loading ? <span className="spinner" /> : <IconRefresh />} Refresh
</button>
</div>
@@ -257,8 +343,18 @@ export default function Dashboard({ config, navidromeConnected }: Props) {
<MiniStat icon={<IconLayers />} value={fmtNumber(n?.genre_count)} label="Genres" />
</div>
<div>
<div className="section-label" style={{ margin: "4px 0 8px" }}>
Audio formats
<div className="row between" style={{ margin: "4px 0 8px", alignItems: "center" }}>
<span className="section-label" style={{ margin: 0 }}>
Audio formats
</span>
<button
className="btn btn-sm"
onClick={() => loadFormats(true)}
disabled={formatsLoading || !n?.connected}
title="Rescan track formats (otherwise cached for the session)"
>
{formatsLoading ? <span className="spinner" /> : <IconRefresh />}
</button>
</div>
{formatsLoading && !formats ? (
<p className="hint row gap-sm">
@@ -322,24 +418,6 @@ export default function Dashboard({ config, navidromeConnected }: Props) {
<div className="panel-head">
<IconUser className="dim" />
<h3 className="grow">User Activity</h3>
{activitySummary && (
<div className="row wrap gap-sm" style={{ justifyContent: "flex-end" }}>
<span className="badge">{activitySummary.user_count} users</span>
<span className="badge">{activitySummary.device_count} devices</span>
{activitySummary.platforms.android > 0 && (
<span className="badge badge-ok">Android {activitySummary.platform_pct.android}%</span>
)}
{activitySummary.platforms.ios > 0 && (
<span className="badge badge-accent">iOS {activitySummary.platform_pct.ios}%</span>
)}
{activitySummary.platforms.web > 0 && (
<span className="badge badge-warn">Web {activitySummary.platform_pct.web}%</span>
)}
{activitySummary.platforms.other > 0 && (
<span className="badge">Other {activitySummary.platform_pct.other}%</span>
)}
</div>
)}
</div>
{!activity ? (
<div className="panel-body">
@@ -348,40 +426,69 @@ export default function Dashboard({ config, navidromeConnected }: Props) {
) : activity.length === 0 ? (
<Empty icon={<IconUser />}>No Emby users found.</Empty>
) : (
<div style={{ overflowX: "auto" }}>
<table className="data-table">
<thead>
<tr>
<th>User</th>
<th>Last login (NZ)</th>
<th>When</th>
<th>IP address</th>
<th>Device</th>
</tr>
</thead>
<tbody>
{activity.map((u) => {
const when = u.last_activity || u.last_login;
return (
<tr key={u.id}>
<td>
<div className="row gap-sm">
<Avatar name={u.name} />
<span className="cell-strong">{u.name}</span>
<div className="panel-body activity-shell">
{activitySummary && (
<div className="activity-summary-grid">
<div className="activity-summary-card">
<span className="activity-summary-label">Active users</span>
<strong>{fmtNumber(activitySummary.user_count)}</strong>
<span className="activity-summary-sub">Recently seen across Emby</span>
</div>
<div className="activity-summary-card">
<span className="activity-summary-label">Devices</span>
<strong>{fmtNumber(activitySummary.device_count)}</strong>
<span className="activity-summary-sub">Distinct clients reported</span>
</div>
{platformSummaryItems(activitySummary).map((item) => (
<div className="activity-summary-card activity-summary-platform" key={item.key}>
<span className={`activity-summary-icon ${item.key}`}>{item.icon}</span>
<div>
<span className="activity-summary-label">{item.label}</span>
<strong>{item.pct}%</strong>
</div>
<span className="activity-summary-sub">{fmtNumber(item.count)} devices</span>
</div>
))}
</div>
)}
<div className="activity-card-grid">
{activity.map((u) => {
const when = u.last_activity || u.last_login;
const platform = detectPlatform(u);
const deviceLabel = [u.device, u.client].filter(Boolean).join(" · ") || "Unknown device";
return (
<article className="activity-card" key={u.id}>
<div className="activity-card-head">
<div className="row gap-sm">
<Avatar name={u.name} />
<div className="activity-user-meta">
<div className="activity-user-name">{u.name}</div>
<div className="activity-user-when">{when ? timeAgo(when) : "Never active"}</div>
</div>
</td>
<td className="mono">{formatNZ(u.last_login)}</td>
<td className="cell-sub">{when ? timeAgo(when) : "Never"}</td>
<td className="mono">{u.ip || <span className="dim"></span>}</td>
<td className="cell-sub">
{u.device || "—"}
{u.client ? ` · ${u.client}` : ""}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<span className={platformBadgeClass(platform)}>
{platformIcon(platform)}
{platformLabel(platform)}
</span>
</div>
<div className="activity-device-title">{deviceLabel}</div>
<div className="activity-card-meta">
<div className="activity-meta-block">
<span className="activity-meta-label">Last login</span>
<span className="activity-meta-value mono">{formatNZ(u.last_login)}</span>
</div>
<div className="activity-meta-block">
<span className="activity-meta-label">IP address</span>
<span className="activity-meta-value mono">{u.ip || "—"}</span>
</div>
</div>
</article>
);
})}
</div>
</div>
)}
</div>
+257 -41
View File
@@ -1,52 +1,121 @@
import { useEffect, useState } from "react";
import { apiGet, apiPost } from "../api";
import { PageHead, Loading } from "../components/ui";
import { IconCheck, IconEmby, IconFolder, IconMusic, IconSettings } from "../components/icons";
import { apiGet, apiPost, AppConfig } from "../api";
import { Loading, PageHead } from "../components/ui";
import { IconBook, IconCheck, IconEmby, IconFolder, IconMusic, IconRefresh, IconSettings } from "../components/icons";
import { useToast } from "../lib/toast";
interface SettingsValues {
emby_url: string;
emby_api_key: string;
homescreen_db_path: string;
tmdb_api_key: string;
navidrome_url: string;
navidrome_user: string;
navidrome_password: string;
audiobookshelf_url: string;
audiobookshelf_token: string;
music_root: string;
deploy_nas_host: string;
deploy_nas_user: string;
deploy_nas_password: string;
deploy_remote_app_dir: string;
deploy_music_host_path: string;
}
interface UpdateStatus {
available: boolean;
allowed: boolean;
configured: boolean;
transport: string | null;
transport_ready: boolean;
password_configured: boolean;
client_host: string;
nas_host: string;
nas_user: string;
remote_app_dir: string;
reason: string | null;
runtime: {
running: boolean;
last_started_at: string | null;
last_finished_at: string | null;
last_status: string;
last_message: string | null;
last_output_tail: string[];
};
}
const LABELS: Record<keyof SettingsValues, { label: string; secret?: boolean; placeholder?: string }> = {
emby_url: { label: "Emby URL", placeholder: "http://10.0.0.2:8096" },
emby_api_key: { label: "Emby API key", secret: true },
navidrome_url: { label: "Navidrome URL", placeholder: "http://10.0.0.2:4533" },
navidrome_user: { label: "Navidrome username" },
navidrome_password: { label: "Navidrome password", secret: true },
music_root: { label: "Music library path", placeholder: "/music" },
emby_url: { label: "Server URL", placeholder: "http://10.0.0.2:8096" },
emby_api_key: { label: "API key", secret: true },
homescreen_db_path: { label: "Homescreen DB path", placeholder: "C:\\ProgramData\\Emby-Server\\data\\users.db" },
tmdb_api_key: { label: "TMDB API key", secret: true },
navidrome_url: { label: "Server URL", placeholder: "http://10.0.0.2:4533" },
navidrome_user: { label: "Username" },
navidrome_password: { label: "Password", secret: true },
audiobookshelf_url: { label: "Server URL", placeholder: "http://10.0.0.2:13378" },
audiobookshelf_token: { label: "API token", secret: true },
music_root: { label: "Library path", placeholder: "/music" },
deploy_nas_host: { label: "NAS host", placeholder: "MATT-NAS or 10.0.0.10" },
deploy_nas_user: { label: "NAS SSH user", placeholder: "ssh" },
deploy_nas_password: { label: "NAS SSH password", secret: true },
deploy_remote_app_dir: { label: "Remote app dir", placeholder: "/share/Docker/homelabtoolkit" },
deploy_music_host_path: { label: "Host music path", placeholder: "/share/Movies/Music" },
};
type DotState = "ok" | "off" | "idle";
export default function Settings({ onSaved }: { onSaved?: () => void }) {
const toast = useToast();
const [values, setValues] = useState<SettingsValues | null>(null);
const [saving, setSaving] = useState(false);
const [reveal, setReveal] = useState(false);
const [config, setConfig] = useState<AppConfig | null>(null);
const [navStatus, setNavStatus] = useState<{ configured: boolean; connected: boolean } | null>(null);
const [absStatus, setAbsStatus] = useState<{ configured: boolean; connected: boolean } | null>(null);
const [updateStatus, setUpdateStatus] = useState<UpdateStatus | null>(null);
const [updating, setUpdating] = useState(false);
function loadStatuses() {
apiGet<AppConfig>("/api/config").then(setConfig).catch(() => setConfig(null));
apiGet("/api/navidrome/status").then(setNavStatus).catch(() => setNavStatus(null));
apiGet("/api/audiobookshelf/status").then(setAbsStatus).catch(() => setAbsStatus(null));
apiGet<UpdateStatus>("/api/update/status").then(setUpdateStatus).catch(() => setUpdateStatus(null));
}
useEffect(() => {
apiGet<SettingsValues>("/api/settings")
.then(setValues)
.catch((e) => toast(e.message, "err"));
loadStatuses();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!updating && !updateStatus?.runtime.running) return;
const timer = window.setInterval(() => {
loadStatuses();
}, 1500);
return () => window.clearInterval(timer);
}, [updating, updateStatus?.runtime.running]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!updating || updateStatus?.runtime.running) return;
setUpdating(false);
if (updateStatus?.runtime.last_message) {
toast(updateStatus.runtime.last_message, updateStatus.runtime.last_status === "ok" ? "ok" : "err");
}
}, [updating, updateStatus?.runtime.running, updateStatus?.runtime.last_finished_at]); // eslint-disable-line react-hooks/exhaustive-deps
function set<K extends keyof SettingsValues>(k: K, v: string) {
setValues((s) => (s ? { ...s, [k]: v } : s));
setValues((current) => (current ? { ...current, [k]: v } : current));
}
async function save() {
if (!values) return;
setSaving(true);
try {
const res = await apiPost("/api/settings", values);
await apiPost("/api/settings", values);
toast("Settings saved", "ok");
if (res?.navidrome?.configured) {
toast(res.navidrome.connected ? "Navidrome connected" : `Navidrome: ${res.navidrome.error || "offline"}`, res.navidrome.connected ? "ok" : "err");
}
loadStatuses();
onSaved?.();
} catch (e: any) {
toast(e.message, "err");
@@ -55,34 +124,115 @@ export default function Settings({ onSaved }: { onSaved?: () => void }) {
}
}
async function runUpdate() {
if (!values) return;
setUpdating(true);
try {
await apiPost("/api/settings", values);
const result = await apiPost<{ result: { message: string }; status: UpdateStatus }>("/api/update/run");
setUpdateStatus(result.status);
toast(result.result.message || "Deployment started", "ok");
} catch (e: any) {
toast(e.message, "err");
loadStatuses();
setUpdating(false);
return;
}
}
if (!values) return <Loading />;
// Render inline (not as a nested component) so React keeps input identity
// stable across renders — otherwise each keystroke remounts and drops focus.
const groups: { title: string; icon: React.ReactNode; keys: (keyof SettingsValues)[] }[] = [
{ title: "Emby", icon: <IconEmby />, keys: ["emby_url", "emby_api_key"] },
{ title: "Navidrome", icon: <IconMusic />, keys: ["navidrome_url", "navidrome_user", "navidrome_password"] },
{ title: "Music library", icon: <IconFolder />, keys: ["music_root"] },
const deployConfiguredDraft = !!values.deploy_nas_host.trim() && !!values.deploy_nas_user.trim();
const deployToolsReady = !!updateStatus?.transport_ready;
const deployAllowed = !!updateStatus?.allowed;
const deployReady = deployAllowed && deployConfiguredDraft && deployToolsReady;
const deployStateLabel = updateStatus
? deployReady
? "Ready"
: deployAllowed
? deployConfiguredDraft
? "Missing tools"
: "Needs setup"
: "Local only"
: "Checking";
const deployReason =
!updateStatus
? null
: !deployAllowed
? updateStatus.reason
: !deployConfiguredDraft
? "Set a NAS host and NAS SSH user, then deploy directly from this screen."
: !deployToolsReady
? updateStatus.reason
: updateStatus.reason;
const dot = (configured: boolean, connected: boolean): [DotState, string] =>
!configured ? ["idle", "Not configured"] : connected ? ["ok", "Connected"] : ["off", "Offline"];
const cards: {
title: string;
icon: React.ReactNode;
keys: (keyof SettingsValues)[];
status: [DotState, string];
}[] = [
{
title: "Emby",
icon: <IconEmby />,
keys: ["emby_url", "emby_api_key", "homescreen_db_path", "tmdb_api_key"],
status: dot(!!config?.emby.connected, !!config?.emby.connected),
},
{
title: "Navidrome",
icon: <IconMusic />,
keys: ["navidrome_url", "navidrome_user", "navidrome_password"],
status: dot(!!navStatus?.configured, !!navStatus?.connected),
},
{
title: "Audiobookshelf",
icon: <IconBook />,
keys: ["audiobookshelf_url", "audiobookshelf_token"],
status: dot(!!absStatus?.configured, !!absStatus?.connected),
},
{
title: "Music library",
icon: <IconFolder />,
keys: ["music_root"],
status: config?.music.available ? (["ok", "Mounted"] as [DotState, string]) : (["off", "Not mounted"] as [DotState, string]),
},
];
return (
<>
<PageHead title="Settings" icon={<IconSettings />}>
Configure connections without touching environment variables. Saved settings are written to the app's config
file and applied immediately — they override the deploy-time defaults.
</PageHead>
<div className="page-toolbar">
<PageHead title="Settings" icon={<IconSettings />} />
<div className="page-toolbar-actions">
<label className="chip" style={{ cursor: "pointer" }}>
<input type="checkbox" checked={reveal} onChange={(e) => setReveal(e.target.checked)} /> Show secrets
</label>
<button className="btn btn-primary" onClick={save} disabled={saving}>
{saving ? <span className="spinner" /> : <IconCheck />} Save
</button>
</div>
</div>
<div className="col" style={{ maxWidth: 640, gap: 16 }}>
{groups.map((g) => (
<div className="panel" key={g.title}>
<div className="section-label">Connections</div>
<div className="settings-grid">
{cards.map((c) => (
<div className="panel" key={c.title}>
<div className="panel-head">
<div className="stat-icon" style={{ width: 30, height: 30, borderRadius: 8 }}>
{g.icon}
<div className="stat-icon" style={{ width: 32, height: 32, borderRadius: 9 }}>
{c.icon}
</div>
<h3>{g.title}</h3>
<h3 className="grow">{c.title}</h3>
<span className="status-chip" style={{ padding: "5px 9px", background: "transparent", border: 0 }}>
<span className={`dot ${c.status[0] === "ok" ? "" : c.status[0]}`} />
<span className="dim" style={{ fontSize: 12 }}>
{c.status[1]}
</span>
</span>
</div>
<div className="panel-body col" style={{ gap: 14 }}>
{g.keys.map((k) => (
{c.keys.map((k) => (
<div className="field" key={k}>
<label className="field-label">{LABELS[k].label}</label>
<input
@@ -99,18 +249,84 @@ export default function Settings({ onSaved }: { onSaved?: () => void }) {
</div>
</div>
))}
</div>
<p className="hint" style={{ marginTop: 12 }}>
Secrets are stored in plaintext in the app's config file on the server. Use this on a trusted local network.
</p>
<div className="row between">
<label className="chip" style={{ cursor: "pointer" }}>
<input type="checkbox" checked={reveal} onChange={(e) => setReveal(e.target.checked)} /> Show secrets
</label>
<button className="btn btn-primary" onClick={save} disabled={saving}>
{saving ? <span className="spinner" /> : <IconCheck />} Save settings
</button>
<div className="section-label" style={{ marginTop: 28 }}>Update</div>
<div className="panel" style={{ maxWidth: 980 }}>
<div className="panel-head">
<div className="stat-icon" style={{ width: 32, height: 32, borderRadius: 9 }}>
<IconRefresh />
</div>
<h3 className="grow">Deploy to Docker host</h3>
{updateStatus ? (
<span className="status-chip" style={{ padding: "5px 9px", background: "transparent", border: 0 }}>
<span className={`dot ${deployReady ? "" : deployAllowed ? "off" : "idle"}`} />
<span className="dim" style={{ fontSize: 12 }}>
{deployStateLabel}
</span>
</span>
) : null}
</div>
<div className="panel-body col" style={{ gap: 14 }}>
<div className="settings-update-grid">
{(["deploy_nas_host", "deploy_nas_user", "deploy_nas_password", "deploy_remote_app_dir", "deploy_music_host_path"] as const).map((k) => (
<div className="field" key={k}>
<label className="field-label">{LABELS[k].label}</label>
<input
className="input"
type={LABELS[k].secret && !reveal ? "password" : "text"}
autoComplete="off"
spellCheck={false}
placeholder={LABELS[k].placeholder}
value={values[k]}
onChange={(e) => set(k, e.target.value)}
/>
</div>
))}
</div>
<div className="row gap-sm" style={{ flexWrap: "wrap" }}>
<button className="btn btn-primary" onClick={runUpdate} disabled={updating || !!updateStatus?.runtime.running || !deployReady}>
{updating || updateStatus?.runtime.running ? <span className="spinner" /> : <IconRefresh />} Deploy now
</button>
<button className="btn btn-sm" onClick={loadStatuses}>
<IconRefresh /> Refresh status
</button>
{updateStatus?.runtime.last_finished_at ? <span className="badge">Last run: {updateStatus.runtime.last_finished_at}</span> : null}
{updateStatus?.runtime.last_status && updateStatus.runtime.last_status !== "idle" ? (
<span className={`badge ${updateStatus.runtime.last_status === "ok" ? "badge-ok" : updateStatus.runtime.last_status === "running" ? "badge-accent" : "badge-bad"}`}>
{updateStatus.runtime.last_status}
</span>
) : null}
</div>
<p className="hint" style={{ margin: 0 }}>
Available only when the app is opened from a local/private address like <code>127.0.0.1</code> or <code>10.0.0.124</code>. The app now deploys directly over Python SSH, syncing the repo and rebuilding Docker on the configured NAS host without relying on PowerShell or interactive prompts.
</p>
<p className="hint" style={{ margin: 0 }}>
The remote compose file is rendered from these deploy settings, including the NAS-side music bind mount path.
</p>
{updateStatus?.client_host ? <p className="hint" style={{ margin: 0 }}>Detected client: <code>{updateStatus.client_host}</code></p> : null}
{updateStatus?.transport ? <p className="hint" style={{ margin: 0 }}>Transport: <code>{updateStatus.transport}</code> · Auth: <code>{updateStatus.password_configured ? "saved password" : "SSH keys / agent"}</code></p> : null}
{deployReason ? <p className="hint" style={{ margin: 0, color: "var(--red)" }}>{deployReason}</p> : null}
{updateStatus?.runtime.running ? <p className="hint" style={{ margin: 0 }}>Deployment in progress. Status refreshes automatically.</p> : null}
{updateStatus?.runtime.last_message ? <p className="hint" style={{ margin: 0 }}>Last result: {updateStatus.runtime.last_message}</p> : null}
{updateStatus?.runtime.last_output_tail?.length ? (
<div className="field">
<label className="field-label">Recent deploy output</label>
<div className="console" style={{ maxHeight: 260 }}>
{updateStatus.runtime.last_output_tail.map((line, index) => (
<div className="log-line" key={`${index}-${line}`}>
<span>{line}</span>
</div>
))}
</div>
</div>
) : null}
</div>
<p className="hint">
Secrets are stored in plaintext in the app's config file on the server. Use this on a trusted local network.
</p>
</div>
</>
);
+390
View File
@@ -0,0 +1,390 @@
import { useEffect, useMemo, useState } from "react";
import { apiGet, apiPost } from "../api";
import { Empty, Loading, PageHead } from "../components/ui";
import {
IconCalendar,
IconCheck,
IconChevron,
IconClock,
IconDisc,
IconEmby,
IconMusic,
IconPlay,
IconRefresh,
IconSettings,
IconTrash,
} from "../components/icons";
import { useToast } from "../lib/toast";
interface PrerollSettings {
preroll_enabled: boolean;
preroll_active_dir: string;
preroll_inactive_dir: string;
preroll_state_file: string;
preroll_weekday: number;
preroll_time: string;
}
interface PrerollTaskStatus {
enabled: boolean;
next_run_at: string | null;
due_now: boolean;
schedule_error: string | null;
runtime: {
running: boolean;
last_message: string | null;
};
state: {
last_rotation?: string | null;
active_file?: string | null;
};
}
interface CleanupTask {
id: string;
section: "emby" | "navidrome" | string;
section_title: string;
title: string;
description: string;
supports_run: boolean;
supports_automation: boolean;
requires?: string | null;
run_label: string;
settings: {
automation_enabled: boolean;
weekday: number;
time: string;
retention_days: number;
};
status: {
last_run_at?: string;
last_status?: string;
last_result?: { message?: string } | null;
};
next_run_at: string | null;
schedule_error: string | null;
}
const WEEKDAYS = [
{ value: 0, label: "Monday" },
{ value: 1, label: "Tuesday" },
{ value: 2, label: "Wednesday" },
{ value: 3, label: "Thursday" },
{ value: 4, label: "Friday" },
{ value: 5, label: "Saturday" },
{ value: 6, label: "Sunday" },
];
const SECTION_ORDER = ["emby", "navidrome"];
export default function Tasks() {
const toast = useToast();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [prerollSaving, setPrerollSaving] = useState(false);
const [prerollBusy, setPrerollBusy] = useState(false);
const [tasks, setTasks] = useState<CleanupTask[]>([]);
const [expanded, setExpanded] = useState<Record<string, boolean>>({ preroll: true });
const [prerollSettings, setPrerollSettings] = useState<PrerollSettings | null>(null);
const [prerollStatus, setPrerollStatus] = useState<PrerollTaskStatus | null>(null);
async function load() {
setLoading(true);
try {
const [preroll, cleanup] = await Promise.all([
apiGet<{ settings: PrerollSettings; status: PrerollTaskStatus }>("/api/tasks/preroll"),
apiGet<{ tasks: CleanupTask[] }>("/api/tasks/cleanup"),
]);
setPrerollSettings(preroll.settings);
setPrerollStatus(preroll.status);
setTasks(cleanup.tasks);
setExpanded((current) => {
const next = { ...current };
for (const task of cleanup.tasks) {
if (!(task.id in next)) next[task.id] = false;
}
return next;
});
} catch (e: any) {
toast(e.message, "err");
} finally {
setLoading(false);
}
}
useEffect(() => {
load();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const sections = useMemo(() => {
const grouped = new Map<string, { title: string; tasks: CleanupTask[] }>();
for (const task of tasks) {
if (!grouped.has(task.section)) grouped.set(task.section, { title: task.section_title, tasks: [] });
grouped.get(task.section)!.tasks.push(task);
}
const orderedKeys = [...SECTION_ORDER.filter((key) => grouped.has(key)), ...[...grouped.keys()].filter((key) => !SECTION_ORDER.includes(key))];
return orderedKeys.map((key) => ({ key, ...grouped.get(key)! }));
}, [tasks]);
function setPreroll<K extends keyof PrerollSettings>(key: K, value: PrerollSettings[K]) {
setPrerollSettings((current) => (current ? { ...current, [key]: value } : current));
}
function setTask(taskId: string, patch: Partial<CleanupTask["settings"]>) {
setTasks((current) => current.map((task) => (task.id === taskId ? { ...task, settings: { ...task.settings, ...patch } } : task)));
}
async function savePreroll() {
if (!prerollSettings) return;
setPrerollSaving(true);
try {
await apiPost("/api/settings", prerollSettings);
toast("System task settings saved", "ok");
await load();
} catch (e: any) {
toast(e.message, "err");
} finally {
setPrerollSaving(false);
}
}
async function saveCleanupTasks() {
setSaving(true);
try {
const payload = Object.fromEntries(tasks.map((task) => [task.id, task.settings]));
const res = await apiPost<{ tasks: CleanupTask[] }>("/api/tasks/cleanup/settings", { emby_tasks: payload });
setTasks(res.tasks);
toast("Task automation saved", "ok");
} catch (e: any) {
toast(e.message, "err");
} finally {
setSaving(false);
}
}
async function runPrerollNow() {
setPrerollBusy(true);
try {
const res = await apiPost<{ status: PrerollTaskStatus; result: { message: string } }>("/api/tasks/preroll/run");
setPrerollStatus(res.status);
toast(res.result.message || "Preroll rotated", "ok");
} catch (e: any) {
toast(e.message, "err");
} finally {
setPrerollBusy(false);
}
}
async function runTask(taskId: string, dryRun: boolean) {
try {
await apiPost("/api/tasks/cleanup/settings", { emby_tasks: Object.fromEntries(tasks.map((task) => [task.id, task.settings])) });
const res = await apiPost<{ tasks: CleanupTask[]; result: { message: string } }>(`/api/tasks/cleanup/${taskId}/run`, { dryRun });
setTasks(res.tasks);
toast(res.result.message || "Task completed", "ok");
} catch (e: any) {
toast(e.message, "err");
}
}
function taskIcon(section: string) {
if (section === "emby") return <IconEmby />;
if (section === "navidrome") return <IconMusic />;
return <IconDisc />;
}
function requirementHint(task: CleanupTask) {
if (task.requires === "emby_server_data") {
return "This one needs Emby's internal server-data path mounted into HomelabToolkit before it can safely inspect or delete files.";
}
if (task.requires === "music_root") {
return "This task needs the configured music root mounted into HomelabToolkit so it can inspect and update your library files.";
}
return null;
}
if (loading || !prerollSettings) return <Loading />;
return (
<>
<div className="row between" style={{ alignItems: "flex-start" }}>
<PageHead title="Tasks" icon={<IconTrash />} />
<div className="row gap-sm">
<button className="btn btn-sm" onClick={load}>
<IconRefresh /> Reload
</button>
<button className="btn btn-primary" onClick={saveCleanupTasks} disabled={saving}>
{saving ? <span className="spinner" /> : <IconCheck />} Save Task Automation
</button>
</div>
</div>
<div className="section-label">System</div>
<div className="task-stack" style={{ marginBottom: 28 }}>
<div className="panel task-panel">
<button className="task-toggle" onClick={() => setExpanded((current) => ({ ...current, preroll: !current.preroll }))}>
<div className="stat-icon" style={{ width: 32, height: 32, borderRadius: 9 }}>
<IconCalendar />
</div>
<div className="task-meta grow">
<div className="task-title-row">
<h3>Rotate Emby prerolls</h3>
<span className={`badge ${prerollStatus?.enabled ? "badge-ok" : ""}`}>{prerollStatus?.enabled ? "scheduled" : "disabled"}</span>
</div>
<div className="task-summary">
Weekly on {WEEKDAYS.find((day) => day.value === prerollSettings.preroll_weekday)?.label ?? "Monday"} at {prerollSettings.preroll_time}
</div>
</div>
<IconChevron className={`nav-caret ${expanded.preroll ? "open" : ""}`} />
</button>
{expanded.preroll ? (
<div className="panel-body col task-body" style={{ gap: 14 }}>
<div className="row gap-sm" style={{ flexWrap: "wrap" }}>
<button className="btn btn-sm btn-primary" onClick={runPrerollNow} disabled={prerollBusy || prerollStatus?.runtime.running}>
{prerollBusy || prerollStatus?.runtime.running ? <span className="spinner" /> : <IconPlay />} Run now
</button>
<button className="btn btn-sm" onClick={savePreroll} disabled={prerollSaving}>
{prerollSaving ? <span className="spinner" /> : <IconSettings />} Save schedule
</button>
{prerollStatus?.next_run_at ? <span className="badge">Next run: {prerollStatus.next_run_at}</span> : null}
{prerollStatus?.state?.last_rotation ? <span className="badge">Last run: {prerollStatus.state.last_rotation}</span> : null}
</div>
<label className="chip" style={{ cursor: "pointer", width: "fit-content" }}>
<input type="checkbox" checked={prerollSettings.preroll_enabled} onChange={(e) => setPreroll("preroll_enabled", e.target.checked)} /> Enable automation
</label>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))", gap: 14 }}>
<div className="field">
<label className="field-label">
<IconCalendar /> Weekday
</label>
<select className="input" value={prerollSettings.preroll_weekday} onChange={(e) => setPreroll("preroll_weekday", Number(e.target.value))}>
{WEEKDAYS.map((day) => (
<option key={day.value} value={day.value}>
{day.label}
</option>
))}
</select>
</div>
<div className="field">
<label className="field-label">
<IconClock /> Time
</label>
<input className="input" type="time" value={prerollSettings.preroll_time} onChange={(e) => setPreroll("preroll_time", e.target.value)} />
</div>
</div>
<div className="field">
<label className="field-label">Active preroll folder</label>
<input className="input" value={prerollSettings.preroll_active_dir} onChange={(e) => setPreroll("preroll_active_dir", e.target.value)} />
</div>
<div className="field">
<label className="field-label">Inactive preroll folder</label>
<input className="input" value={prerollSettings.preroll_inactive_dir} onChange={(e) => setPreroll("preroll_inactive_dir", e.target.value)} />
</div>
<div className="field">
<label className="field-label">State file</label>
<input className="input" value={prerollSettings.preroll_state_file} onChange={(e) => setPreroll("preroll_state_file", e.target.value)} />
</div>
{prerollStatus?.schedule_error ? <p className="hint" style={{ margin: 0, color: "var(--bad)" }}>Schedule error: {prerollStatus.schedule_error}</p> : null}
{prerollStatus?.runtime.last_message ? <p className="hint" style={{ margin: 0 }}>Last result: {prerollStatus.runtime.last_message}</p> : null}
</div>
) : null}
</div>
</div>
{sections.map((section) => (
<div key={section.key} style={{ marginBottom: 28 }}>
<div className="section-label">{section.title}</div>
<div className="task-stack">
{section.tasks.map((task) => {
const hint = requirementHint(task);
return (
<div className="panel task-panel" key={task.id}>
<button className="task-toggle" onClick={() => setExpanded((current) => ({ ...current, [task.id]: !current[task.id] }))}>
<div className="stat-icon" style={{ width: 32, height: 32, borderRadius: 9 }}>
{taskIcon(task.section)}
</div>
<div className="task-meta grow">
<div className="task-title-row">
<h3>{task.title}</h3>
<span className={`badge ${task.supports_run ? "badge-ok" : "badge-warn"}`}>{task.supports_run ? "ready" : "needs mount"}</span>
{task.settings.automation_enabled && task.supports_automation ? <span className="badge badge-accent">automation on</span> : null}
</div>
<div className="task-summary">
{task.description}
{task.next_run_at ? ` · Next run ${task.next_run_at}` : ""}
</div>
</div>
<IconChevron className={`nav-caret ${expanded[task.id] ? "open" : ""}`} />
</button>
{expanded[task.id] ? (
<div className="panel-body col task-body" style={{ gap: 14 }}>
<div className="row gap-sm" style={{ flexWrap: "wrap" }}>
<button className="btn btn-sm" onClick={() => runTask(task.id, true)} disabled={!task.supports_run}>
<IconPlay /> Preview
</button>
<button className="btn btn-sm btn-primary" onClick={() => runTask(task.id, false)} disabled={!task.supports_run}>
<IconTrash /> {task.run_label}
</button>
{task.status?.last_run_at ? <span className="badge">Last run: {task.status.last_run_at}</span> : null}
{task.status?.last_status ? <span className={`badge ${task.status.last_status === "ok" ? "badge-ok" : "badge-bad"}`}>{task.status.last_status}</span> : null}
</div>
{task.supports_automation ? (
<>
<label className="chip" style={{ cursor: "pointer", width: "fit-content" }}>
<input
type="checkbox"
checked={task.settings.automation_enabled}
onChange={(e) => setTask(task.id, { automation_enabled: e.target.checked })}
/>{" "}
Enable weekly automation
</label>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", gap: 14 }}>
<div className="field">
<label className="field-label">Weekday</label>
<select className="input" value={task.settings.weekday} onChange={(e) => setTask(task.id, { weekday: Number(e.target.value) })}>
{WEEKDAYS.map((day) => (
<option key={day.value} value={day.value}>
{day.label}
</option>
))}
</select>
</div>
<div className="field">
<label className="field-label">Time</label>
<input className="input" type="time" value={task.settings.time} onChange={(e) => setTask(task.id, { time: e.target.value })} />
</div>
{"retention_days" in task.settings ? (
<div className="field">
<label className="field-label">Retention days</label>
<input
className="input"
type="number"
min={1}
max={3650}
value={task.settings.retention_days}
onChange={(e) => setTask(task.id, { retention_days: Number(e.target.value) || 1 })}
/>
</div>
) : null}
</div>
</>
) : (
<p className="hint" style={{ margin: 0 }}>
This task cannot be automated from the current deployment.
</p>
)}
{task.schedule_error ? <p className="hint" style={{ margin: 0, color: "var(--bad)" }}>Schedule error: {task.schedule_error}</p> : null}
{task.status?.last_result?.message ? <p className="hint" style={{ margin: 0 }}>Last result: {task.status.last_result.message}</p> : null}
{hint ? <p className="hint" style={{ margin: 0 }}>{hint}</p> : null}
</div>
) : null}
</div>
);
})}
</div>
</div>
))}
{!tasks.length ? <Empty icon={<IconTrash />}>No cleanup tasks available.</Empty> : null}
</>
);
}
@@ -0,0 +1,144 @@
import { useEffect, useState } from "react";
import { apiGet } from "../../api";
import { PageHead, StatCard, Empty, Loading, fmtNumber } from "../../components/ui";
import { IconBook, IconClock, IconHeadphones, IconRefresh, IconUser } from "../../components/icons";
import { useToast } from "../../lib/toast";
interface LibraryView {
id: string;
name: string;
media_type: string;
items: number;
authors: number;
duration: number;
size: number;
}
interface Stats {
library_count: number;
book_count: number;
podcast_count: number;
author_count: number;
total_duration: number;
total_size: number;
num_audio_tracks: number;
libraries: LibraryView[];
}
function fmtHours(seconds: number): string {
if (!seconds) return "0h";
const h = Math.floor(seconds / 3600);
if (h >= 24) return `${(h / 24).toFixed(0)}d ${h % 24}h`;
const m = Math.floor((seconds % 3600) / 60);
return h > 0 ? `${h}h ${m}m` : `${m}m`;
}
function fmtSize(bytes: number): string {
if (!bytes) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
let v = bytes;
let i = 0;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i++;
}
return `${v.toFixed(1)} ${units[i]}`;
}
export default function AudiobookshelfOverview() {
const toast = useToast();
const [status, setStatus] = useState<{ connected: boolean; configured: boolean; error?: string } | null>(null);
const [stats, setStats] = useState<Stats | null>(null);
const [loading, setLoading] = useState(true);
function load() {
setLoading(true);
apiGet("/api/audiobookshelf/status")
.then((s) => {
setStatus(s);
if (s.connected) {
return apiGet<Stats>("/api/audiobookshelf/stats").then(setStats);
}
setStats(null);
})
.catch(() => setStatus({ connected: false, configured: false }))
.finally(() => setLoading(false));
}
useEffect(load, []);
return (
<>
<div className="row between" style={{ alignItems: "flex-start" }}>
<PageHead title="Audiobookshelf" icon={<IconBook />} />
<button className="btn btn-sm" onClick={load} disabled={loading}>
{loading ? <span className="spinner" /> : <IconRefresh />} Refresh
</button>
</div>
{loading ? (
<Loading label="Loading Audiobookshelf stats…" />
) : !status?.configured ? (
<div className="panel">
<Empty icon={<IconBook />}>
Audiobookshelf is not configured. Add your server URL and API token in <strong>Settings</strong>.
</Empty>
</div>
) : !status.connected ? (
<div className="panel">
<Empty icon={<IconBook />}>Could not connect to Audiobookshelf. {status.error}</Empty>
</div>
) : (
<>
<div className="stat-row" style={{ marginBottom: 14 }}>
<StatCard icon={<IconBook />} value={fmtNumber(stats?.book_count)} label="Audiobooks" />
<StatCard icon={<IconHeadphones />} value={fmtNumber(stats?.podcast_count)} label="Podcasts" />
<StatCard icon={<IconUser />} value={fmtNumber(stats?.author_count)} label="Authors" />
<StatCard icon={<IconClock />} value={fmtHours(stats?.total_duration || 0)} label="Total runtime" />
</div>
<div className="stat-row" style={{ marginBottom: 26 }}>
<StatCard icon={<IconBook />} value={fmtNumber(stats?.library_count)} label="Libraries" />
<StatCard icon={<IconHeadphones />} value={fmtNumber(stats?.num_audio_tracks)} label="Audio tracks" />
<StatCard icon={<IconBook />} value={fmtSize(stats?.total_size || 0)} label="On disk" />
</div>
<div className="panel">
<div className="panel-head">
<h3 className="grow">Libraries</h3>
<span className="sub">{stats?.libraries.length || 0}</span>
</div>
{!stats?.libraries.length ? (
<Empty icon={<IconBook />}>No libraries found.</Empty>
) : (
<table className="data-table">
<thead>
<tr>
<th>Library</th>
<th>Type</th>
<th>Items</th>
<th>Authors</th>
<th>Runtime</th>
<th>Size</th>
</tr>
</thead>
<tbody>
{stats.libraries.map((lib) => (
<tr key={lib.id}>
<td className="cell-strong">{lib.name}</td>
<td>
<span className={`badge ${lib.media_type === "podcast" ? "badge-warn" : "badge-accent"}`}>
{lib.media_type}
</span>
</td>
<td className="mono">{fmtNumber(lib.items)}</td>
<td className="mono">{fmtNumber(lib.authors)}</td>
<td className="mono cell-sub">{fmtHours(lib.duration)}</td>
<td className="mono cell-sub">{fmtSize(lib.size)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</>
)}
</>
);
}
+1 -4
View File
@@ -99,10 +99,7 @@ export default function Airing() {
return (
<>
<PageHead title="Airing & New Seasons">
Series currently airing in your library. Eligible new-season premieres can be stamped with "New Season"
artwork in one click.
</PageHead>
<PageHead title="Airing & New Seasons" icon={<IconCalendar />} />
<div className="row between wrap" style={{ marginBottom: 18, gap: 12 }}>
<div className="row gap-sm wrap">
@@ -0,0 +1,49 @@
import { PageHead } from "../../components/ui";
import { IconImage, IconUser } from "../../components/icons";
export default function AvatarGenerator() {
return (
<>
<PageHead title="Avatar Generator" icon={<IconUser />} />
<div className="panel" style={{ maxWidth: 880 }}>
<div className="panel-head">
<h3 className="grow">Standalone Emby utility</h3>
<span className="badge badge-ok">Python script</span>
</div>
<div className="panel-body col" style={{ gap: 14 }}>
<p className="hint" style={{ margin: 0 }}>
This tool lives in <code>emby-avatar-generator.py</code> and generates rounded user avatar tiles for Emby users.
</p>
<div className="mini-grid">
<div className="mini-stat">
<div className="stat-icon">
<IconUser />
</div>
<div className="stat-meta">
<div className="stat-value" style={{ fontSize: 16 }}>User avatars</div>
<div className="stat-label">Creates initials-based PNG profile images</div>
</div>
</div>
<div className="mini-stat">
<div className="stat-icon">
<IconImage />
</div>
<div className="stat-meta">
<div className="stat-value" style={{ fontSize: 16 }}>Modern gradients</div>
<div className="stat-label">Mesh backgrounds, shapes, and rounded corners</div>
</div>
</div>
</div>
<div className="field">
<label className="field-label">Script path</label>
<div className="input mono">emby-avatar-generator.py</div>
</div>
<p className="hint" style={{ margin: 0 }}>
This page adds the avatar generator to the Emby tool list. The script itself is still run directly from the workspace.
</p>
</div>
</div>
</>
);
}
+1 -4
View File
@@ -97,10 +97,7 @@ export default function BulkAssign() {
return (
<>
<PageHead title="Bulk Assign">
Generate and push landscape thumbnails across many titles at once. Eligible titles need an Emby primary, logo
and backdrop.
</PageHead>
<PageHead title="Bulk Assign" icon={<IconGrid />} />
<div className="row between wrap" style={{ marginBottom: 16, gap: 12 }}>
<div className="row gap-sm wrap">
+1 -1
View File
@@ -101,7 +101,7 @@ export default function Collections() {
return (
<>
<PageHead title="Collection Art">Generate cover artwork for your Emby collections with custom titling.</PageHead>
<PageHead title="Collection Art" icon={<IconLayers />} />
<div className="workbench">
<div className="panel" style={{ display: "flex", flexDirection: "column", maxHeight: "calc(100vh - 200px)" }}>
+1 -4
View File
@@ -113,10 +113,7 @@ export default function Favorites() {
return (
<>
<PageHead title="User Favorites">
Browse any Emby collection with per-user watched status, prune watched items, and top up with personalized
recommendations. Both actions default to a safe dry run.
</PageHead>
<PageHead title="User Favorites" icon={<IconHeart />} />
<div className="panel" style={{ marginBottom: 18 }}>
<div className="panel-body row wrap" style={{ gap: 14 }}>
+1 -3
View File
@@ -148,9 +148,7 @@ export default function Generator() {
return (
<>
<PageHead title="Thumbnail Generator">
Composite a landscape thumbnail from an item's poster, logo and backdrop, then push it back to Emby.
</PageHead>
<PageHead title="Thumbnail Generator" icon={<IconImage />} />
<div className="workbench">
{/* search column */}
@@ -0,0 +1,920 @@
import { useEffect, useMemo, useState } from "react";
import { apiGet, apiPost, uploadHomescreenDb } from "../../api";
import { Empty, Loading, PageHead, formatNZ, timeAgo } from "../../components/ui";
import {
IconCheck,
IconEmby,
IconLayers,
IconRefresh,
IconSearch,
IconTrash,
IconUser,
} from "../../components/icons";
import { useToast } from "../../lib/toast";
interface HomescreenSettings {
homescreen_db_path: string;
tmdb_api_key: string;
}
interface UploadedDb {
upload_id: string;
filename: string;
size_bytes: number;
uploaded_at: string | null;
sha256: string;
path: string;
}
interface HomescreenEnums {
section_types: { value: string; label: string }[];
collection_types: { value: string; label: string }[];
item_types: string[];
sort_options: { value: string; label: string }[];
image_types: { value: string; label: string }[];
}
interface HomescreenUser {
id: string | number;
name: string;
dbName?: string;
guid?: string;
embyGuid?: string;
embyName?: string | null;
sections: Record<string, any>[];
details?: {
sourceTable?: string | null;
lastLoginDate?: string | null;
lastActivityDate?: string | null;
importedCollectionsCount?: number;
};
match?: {
ok?: boolean;
mismatchedSectionUserIds?: string[];
missingSectionUserIds?: number;
};
}
interface DbReadPayload {
users: HomescreenUser[];
validation: {
userSource: string | null;
userCount: number;
settingsCount: number;
matchedUsers: number;
mismatchedUsers: number;
normalizedUsers: number;
missingSectionUserIds: number;
orphanedSettingsUserIds: string[];
embyCacheMatchedUsers?: number;
embyCacheUserCount?: number;
embyCacheLastSyncedAt?: string | null;
};
source?: {
mode: "upload" | "path";
db_path: string;
upload?: UploadedDb | null;
};
}
interface EmbyUsersPayload {
users: { embyGuid: string; name: string }[];
source: "live" | "cache";
lastSyncedAt: string | null;
message?: string;
}
interface UserContextPayload {
views: { id: string; name: string; type: string }[];
recentlyPlayed: { id: string; name: string; type: string; seriesName?: string | null; datePlayed?: string | null }[];
excludedFolderLookup: Record<string, { name: string; type: string }>;
source: "live" | "cache";
lastSyncedAt?: string | null;
message?: string;
}
function makeId() {
return crypto.randomUUID().replace(/-/g, "").slice(0, 32);
}
function createEmptySection(userId: string) {
return {
UserId: userId,
Name: "New Section",
CustomName: "New Section",
Id: makeId(),
SectionType: "items",
ImageType: "Thumb",
CollectionType: "movies",
SortBy: "Random",
SortOrder: "Descending",
Monitor: [],
ItemTypes: ["Movie"],
ExcludedFolders: [],
CardSizeOffset: 0,
IncludeNextUpInResume: true,
Query: {
StudioIds: [],
TagIds: [],
GenreIds: [],
CollectionTypes: [],
IsPlayed: false,
},
};
}
function createRecentlyWatchedSection(userId: string, userName = "") {
const label = userName ? `Recently Watched - ${userName}` : "Recently Watched";
return {
UserId: userId,
Name: label,
CustomName: label,
Id: makeId(),
SectionType: "items",
ImageType: "Thumb",
CollectionType: "",
SortBy: "DatePlayed",
SortOrder: "Descending",
Monitor: [],
ItemTypes: ["Movie", "Series"],
ExcludedFolders: [],
CardSizeOffset: 0,
IncludeNextUpInResume: true,
Query: {
StudioIds: [],
TagIds: [],
GenreIds: [],
CollectionTypes: [],
IsPlayed: true,
},
};
}
function createCollectionSection(userId: string) {
return {
UserId: userId,
Name: "New Collection",
CustomName: "New Collection",
Id: makeId(),
SectionType: "boxset",
ImageType: "Thumb",
ItemTypes: [],
SortBy: "Random",
SortOrder: "Descending",
Monitor: [],
ExcludedFolders: [],
CardSizeOffset: 0,
IncludeNextUpInResume: true,
ParentItem: {
Name: "New Collection",
Id: "",
},
ParentId: "",
};
}
function cloneSectionsForTarget(sections: Record<string, any>[], targetGuid: string, mode: "append" | "replace", existing: Record<string, any>[]) {
const cloned = sections.map((section) => ({
...JSON.parse(JSON.stringify(section)),
UserId: targetGuid,
Id: makeId(),
}));
return mode === "replace" ? cloned : [...existing, ...cloned];
}
export default function HomescreenEditor() {
const toast = useToast();
const [settings, setSettings] = useState<HomescreenSettings | null>(null);
const [enums, setEnums] = useState<HomescreenEnums | null>(null);
const [users, setUsers] = useState<HomescreenUser[]>([]);
const [originalUsers, setOriginalUsers] = useState<HomescreenUser[]>([]);
const [validation, setValidation] = useState<DbReadPayload["validation"] | null>(null);
const [selectedUserId, setSelectedUserId] = useState<string>("");
const [selectedSectionIndex, setSelectedSectionIndex] = useState(0);
const [sectionJson, setSectionJson] = useState("");
const [sqlPreview, setSqlPreview] = useState("");
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [savingSettings, setSavingSettings] = useState(false);
const [userContext, setUserContext] = useState<UserContextPayload | null>(null);
const [contextBusy, setContextBusy] = useState(false);
const [syncSourceId, setSyncSourceId] = useState("");
const [syncTargetIds, setSyncTargetIds] = useState<string[]>([]);
const [syncMode, setSyncMode] = useState<"append" | "replace">("append");
const [uploadedDb, setUploadedDb] = useState<UploadedDb | null>(null);
const [uploadingDb, setUploadingDb] = useState(false);
useEffect(() => {
async function load() {
setLoading(true);
try {
const [allSettings, enumPayload, dbSource] = await Promise.all([
apiGet<any>("/api/settings"),
apiGet<HomescreenEnums>("/api/homescreen/enums"),
apiGet<{ upload: UploadedDb | null }>("/api/homescreen/db-source"),
]);
setSettings({
homescreen_db_path: allSettings.homescreen_db_path || "",
tmdb_api_key: allSettings.tmdb_api_key || "",
});
setEnums(enumPayload);
setUploadedDb(dbSource.upload || null);
} catch (e: any) {
toast(e.message, "err");
} finally {
setLoading(false);
}
}
load();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const selectedUser = useMemo(
() => users.find((user) => String(user.id) === selectedUserId) || null,
[users, selectedUserId]
);
const selectedSection = selectedUser?.sections?.[selectedSectionIndex] || null;
const filteredUsers = useMemo(() => {
const needle = search.trim().toLowerCase();
if (!needle) return users;
return users.filter((user) =>
[user.name, user.dbName, user.embyName, user.embyGuid].some((value) => String(value || "").toLowerCase().includes(needle))
);
}, [users, search]);
const changes = useMemo(() => {
const originalLookup = new Map(originalUsers.map((user) => [String(user.id), user]));
return users
.filter((user) => JSON.stringify(user.sections || []) !== JSON.stringify(originalLookup.get(String(user.id))?.sections || []))
.map((user) => ({ userId: user.id, sections: user.sections, name: user.name }));
}, [originalUsers, users]);
useEffect(() => {
if (!selectedUser) return;
if (!selectedUser.sections.length) {
setSelectedSectionIndex(0);
setSectionJson("");
return;
}
if (selectedSectionIndex >= selectedUser.sections.length) {
setSelectedSectionIndex(0);
}
}, [selectedSectionIndex, selectedUser]);
useEffect(() => {
setSectionJson(selectedSection ? JSON.stringify(selectedSection, null, 2) : "");
}, [selectedSection]);
useEffect(() => {
async function loadContext() {
if (!selectedUser?.embyGuid) {
setUserContext(null);
return;
}
const excludedIds = ((selectedSection?.ExcludedFolders as string[]) || []).join(",");
setContextBusy(true);
try {
const payload = await apiGet<UserContextPayload>(
`/api/homescreen/user-context?embyGuid=${encodeURIComponent(selectedUser.embyGuid)}${excludedIds ? `&excludedIds=${encodeURIComponent(excludedIds)}` : ""}`
);
setUserContext(payload);
} catch (e: any) {
setUserContext({
views: [],
recentlyPlayed: [],
excludedFolderLookup: {},
source: "cache",
message: e.message,
});
} finally {
setContextBusy(false);
}
}
loadContext();
}, [selectedSection?.ExcludedFolders, selectedUser?.embyGuid]);
function patchSettings<K extends keyof HomescreenSettings>(key: K, value: HomescreenSettings[K]) {
setSettings((current) => (current ? { ...current, [key]: value } : current));
}
async function saveEditorSettings() {
if (!settings) return;
setSavingSettings(true);
try {
await apiPost("/api/settings", settings);
toast("Homescreen editor settings saved", "ok");
} catch (e: any) {
toast(e.message, "err");
} finally {
setSavingSettings(false);
}
}
async function loadFromDb() {
if (!uploadedDb && !settings?.homescreen_db_path) {
toast("Upload a users.db extract or set a fallback path first", "err");
return;
}
setBusy(true);
try {
const payload = await apiPost<DbReadPayload>("/api/homescreen/db-read", {
dbPath: settings?.homescreen_db_path,
uploadId: uploadedDb?.upload_id || null,
});
setUsers(payload.users);
setOriginalUsers(JSON.parse(JSON.stringify(payload.users)));
setValidation(payload.validation);
setUploadedDb(payload.source?.upload || uploadedDb || null);
const firstUser = payload.users.find((user) => user.sections?.length > 0) || payload.users[0];
setSelectedUserId(firstUser ? String(firstUser.id) : "");
setSelectedSectionIndex(0);
setSyncSourceId(firstUser ? String(firstUser.id) : "");
toast(`Loaded ${payload.users.length} user(s) from ${payload.source?.mode === "upload" ? "the uploaded users.db" : "users.db path"}`, "ok");
} catch (e: any) {
toast(e.message, "err");
} finally {
setBusy(false);
}
}
async function handleDbUpload(file: File | null) {
if (!file) return;
setUploadingDb(true);
try {
const payload = await uploadHomescreenDb(file);
setUploadedDb(payload.upload);
toast(`Uploaded ${payload.upload.filename}`, "ok");
} catch (e: any) {
toast(e.message, "err");
} finally {
setUploadingDb(false);
}
}
async function refreshEmbyNames() {
setBusy(true);
try {
const payload = await apiGet<EmbyUsersPayload>("/api/homescreen/emby-users");
const nameMap = new Map(payload.users.map((user) => [user.embyGuid, user.name]));
setUsers((current) =>
current.map((user) => {
const name = user.embyGuid ? nameMap.get(String(user.embyGuid).toLowerCase()) : null;
return name ? { ...user, name, embyName: name } : user;
})
);
setOriginalUsers((current) =>
current.map((user) => {
const name = user.embyGuid ? nameMap.get(String(user.embyGuid).toLowerCase()) : null;
return name ? { ...user, name, embyName: name } : user;
})
);
toast(payload.message || `Loaded ${payload.users.length} Emby user name(s) from ${payload.source}`, "ok");
} catch (e: any) {
toast(e.message, "err");
} finally {
setBusy(false);
}
}
function replaceSelectedUser(nextUser: HomescreenUser) {
setUsers((current) => current.map((user) => (String(user.id) === String(nextUser.id) ? nextUser : user)));
}
function updateSelectedSection(mutator: (section: Record<string, any>) => Record<string, any>) {
if (!selectedUser || !selectedSection) return;
const nextSections = selectedUser.sections.map((section, index) => (index === selectedSectionIndex ? mutator(JSON.parse(JSON.stringify(section))) : section));
replaceSelectedUser({ ...selectedUser, sections: nextSections });
}
function addSection(kind: "empty" | "recent" | "collection") {
if (!selectedUser) return;
const userGuid = selectedUser.embyGuid || "";
const next =
kind === "recent"
? createRecentlyWatchedSection(userGuid, selectedUser.name)
: kind === "collection"
? createCollectionSection(userGuid)
: createEmptySection(userGuid);
const nextSections = [...(selectedUser.sections || []), next];
replaceSelectedUser({ ...selectedUser, sections: nextSections });
setSelectedSectionIndex(nextSections.length - 1);
}
function moveSection(direction: -1 | 1) {
if (!selectedUser || !selectedSection) return;
const nextIndex = selectedSectionIndex + direction;
if (nextIndex < 0 || nextIndex >= selectedUser.sections.length) return;
const nextSections = [...selectedUser.sections];
[nextSections[selectedSectionIndex], nextSections[nextIndex]] = [nextSections[nextIndex], nextSections[selectedSectionIndex]];
replaceSelectedUser({ ...selectedUser, sections: nextSections });
setSelectedSectionIndex(nextIndex);
}
function removeSection() {
if (!selectedUser || !selectedSection) return;
const nextSections = selectedUser.sections.filter((_, index) => index !== selectedSectionIndex);
replaceSelectedUser({ ...selectedUser, sections: nextSections });
setSelectedSectionIndex(Math.max(0, selectedSectionIndex - 1));
}
function applySectionJson() {
if (!selectedUser) return;
try {
const parsed = JSON.parse(sectionJson);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("Section JSON must be an object.");
updateSelectedSection(() => parsed);
toast("Section JSON applied", "ok");
} catch (e: any) {
toast(e.message, "err");
}
}
function toggleItemType(itemType: string) {
updateSelectedSection((section) => {
const current = Array.isArray(section.ItemTypes) ? section.ItemTypes : [];
return {
...section,
ItemTypes: current.includes(itemType) ? current.filter((value: string) => value !== itemType) : [...current, itemType],
};
});
}
async function previewSql() {
try {
const payload = await apiPost<{ sql: string }>("/api/homescreen/sql-preview", { users, originalUsers });
setSqlPreview(payload.sql);
} catch (e: any) {
toast(e.message, "err");
}
}
async function writeToDb() {
if ((!uploadedDb && !settings?.homescreen_db_path) || !changes.length) return;
if (!window.confirm(`Write homescreen changes for ${changes.length} user(s) directly to the Emby database?\n\nStop Emby first for safety.`)) return;
setBusy(true);
try {
const payload = await apiPost<{ count: number; normalizedSections: number; source?: { upload?: UploadedDb | null } }>("/api/homescreen/db-write", {
dbPath: settings?.homescreen_db_path,
uploadId: uploadedDb?.upload_id || null,
changes,
});
setOriginalUsers(JSON.parse(JSON.stringify(users)));
setValidation((current) => current ? { ...current, normalizedUsers: payload.normalizedSections } : current);
setUploadedDb(payload.source?.upload || uploadedDb || null);
toast(`Wrote ${payload.count} user(s) to ${uploadedDb ? "the uploaded users.db" : "users.db"}`, "ok");
} catch (e: any) {
toast(e.message, "err");
} finally {
setBusy(false);
}
}
function performSync() {
if (!syncSourceId || !syncTargetIds.length) return;
const source = users.find((user) => String(user.id) === syncSourceId);
if (!source) return;
setUsers((current) =>
current.map((user) => {
if (!syncTargetIds.includes(String(user.id)) || !user.embyGuid) return user;
return {
...user,
sections: cloneSectionsForTarget(source.sections || [], user.embyGuid || "", syncMode, user.sections || []),
};
})
);
toast(`Synced ${source.sections.length} section(s) to ${syncTargetIds.length} user(s)`, "ok");
}
if (loading || !settings || !enums) return <Loading label="Loading homescreen editor…" />;
return (
<>
<div className="row between" style={{ alignItems: "flex-start" }}>
<PageHead title="Homescreen Editor" icon={<IconEmby />} />
<div className="row gap-sm" style={{ flexWrap: "wrap" }}>
<button className="btn btn-sm" onClick={refreshEmbyNames} disabled={busy}>
<IconRefresh /> Refresh Emby names
</button>
<button className="btn btn-sm" onClick={loadFromDb} disabled={busy || uploadingDb}>
<IconLayers /> Load from DB
</button>
<button className="btn btn-sm" onClick={previewSql} disabled={!users.length}>
<IconSearch /> Preview SQL
</button>
<button className="btn btn-primary" onClick={writeToDb} disabled={busy || uploadingDb || !changes.length}>
{busy ? <span className="spinner" /> : <IconCheck />} Write to DB
</button>
</div>
</div>
<div className="workbench" style={{ gridTemplateColumns: "320px 1fr" }}>
<div className="col">
<div className="panel">
<div className="panel-head">
<h3 className="grow">Editor Settings</h3>
</div>
<div className="panel-body col" style={{ gap: 12 }}>
<div className="field">
<label className="field-label">Upload users.db extract</label>
<input
className="input"
type="file"
accept=".db,.sqlite,.sqlite3,application/octet-stream"
onChange={(e) => handleDbUpload(e.target.files?.[0] || null)}
/>
</div>
{uploadedDb ? (
<div className="row wrap" style={{ gap: 8 }}>
<span className="badge badge-ok">Uploaded source</span>
<span className="badge">{uploadedDb.filename}</span>
<span className="badge">{Math.round(uploadedDb.size_bytes / 1024)} KB</span>
{uploadedDb.uploaded_at ? <span className="badge">Uploaded {uploadedDb.uploaded_at}</span> : null}
</div>
) : null}
<div className="field">
<label className="field-label">Fallback users.db path</label>
<input
className="input"
value={settings.homescreen_db_path}
onChange={(e) => patchSettings("homescreen_db_path", e.target.value)}
placeholder="Optional if the app host can read the file directly"
/>
</div>
<div className="field">
<label className="field-label">TMDB API key</label>
<input className="input" type="password" value={settings.tmdb_api_key} onChange={(e) => patchSettings("tmdb_api_key", e.target.value)} />
</div>
<button className="btn btn-sm" onClick={saveEditorSettings} disabled={savingSettings}>
{savingSettings ? <span className="spinner" /> : <IconCheck />} Save editor settings
</button>
<p className="hint" style={{ margin: 0 }}>
Uploading a <code>users.db</code> extract is the portable Docker-safe flow and works anywhere the browser can reach this app. The fallback path is only for direct host filesystem access.
</p>
<p className="hint" style={{ margin: 0 }}>
Stop Emby before writing to <code>users.db</code>. Typical Windows path: <code>C:\ProgramData\Emby-Server\data\users.db</code>
</p>
</div>
</div>
<div className="panel">
<div className="panel-head">
<h3 className="grow">Users</h3>
<span className="sub">{filteredUsers.length}</span>
</div>
<div className="panel-body col" style={{ gap: 10 }}>
<div className="field">
<label className="field-label">Search</label>
<input className="input" value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Search users or Emby GUID" />
</div>
{!filteredUsers.length ? (
<Empty icon={<IconUser />}>Load the Emby users database to begin.</Empty>
) : (
<div style={{ maxHeight: 520, overflow: "auto" }}>
{filteredUsers.map((user) => {
const selected = String(user.id) === selectedUserId;
return (
<button
key={String(user.id)}
className={`nav-item ${selected ? "active" : ""}`}
style={{ width: "100%", justifyContent: "space-between", marginBottom: 6 }}
onClick={() => {
setSelectedUserId(String(user.id));
setSelectedSectionIndex(0);
}}
>
<span style={{ display: "flex", alignItems: "center", gap: 10 }}>
<IconUser />
<span style={{ textAlign: "left" }}>
<div>{user.name}</div>
<div className="hint">{user.sections?.length || 0} sections</div>
</span>
</span>
<span className={`badge ${user.match?.ok ? "badge-ok" : "badge-warn"}`}>{user.match?.ok ? "ok" : "fixes"}</span>
</button>
);
})}
</div>
)}
</div>
</div>
</div>
<div className="col">
{!selectedUser ? (
<Empty icon={<IconEmby />}>Load a homescreen database and select a user to edit.</Empty>
) : (
<>
<div className="panel">
<div className="panel-head">
<h3 className="grow">{selectedUser.name}</h3>
{selectedUser.details?.lastActivityDate ? <span className="badge">Active {timeAgo(selectedUser.details.lastActivityDate)}</span> : null}
{selectedUser.embyGuid ? <span className="badge badge-ok">Linked to Emby</span> : <span className="badge">Unlinked</span>}
</div>
<div className="panel-body col" style={{ gap: 12 }}>
<div className="row gap-sm" style={{ flexWrap: "wrap" }}>
<span className="badge">DB name: {selectedUser.dbName || selectedUser.name}</span>
{selectedUser.embyGuid ? <span className="badge">Emby GUID: {selectedUser.embyGuid}</span> : null}
{selectedUser.details?.lastLoginDate ? <span className="badge">Last login: {formatNZ(selectedUser.details.lastLoginDate)}</span> : null}
{selectedUser.match?.mismatchedSectionUserIds?.length ? <span className="badge badge-warn">Mismatched IDs: {selectedUser.match.mismatchedSectionUserIds.length}</span> : null}
</div>
<div className="row gap-sm" style={{ flexWrap: "wrap" }}>
<button className="btn btn-sm" onClick={() => addSection("empty")}><IconLayers /> Add section</button>
<button className="btn btn-sm" onClick={() => addSection("collection")}><IconLayers /> Add collection row</button>
<button className="btn btn-sm" onClick={() => addSection("recent")}><IconRefresh /> Add recently watched</button>
</div>
</div>
</div>
<div className="workbench" style={{ gridTemplateColumns: "340px 1fr" }}>
<div className="col">
<div className="panel">
<div className="panel-head">
<h3 className="grow">Sections</h3>
<span className="sub">{selectedUser.sections.length}</span>
</div>
<div className="panel-body col" style={{ gap: 8 }}>
{!selectedUser.sections.length ? (
<Empty icon={<IconLayers />}>No homescreen sections for this user yet.</Empty>
) : (
selectedUser.sections.map((section, index) => (
<button
key={String(section.Id || index)}
className={`nav-item ${index === selectedSectionIndex ? "active" : ""}`}
style={{ width: "100%", justifyContent: "space-between" }}
onClick={() => setSelectedSectionIndex(index)}
>
<span style={{ textAlign: "left" }}>
<div>{section.CustomName || section.Name || "Unnamed section"}</div>
<div className="hint">{section.SectionType || "items"}{section.SortBy ? ` · ${section.SortBy}` : ""}</div>
</span>
<span className="badge">{index + 1}</span>
</button>
))
)}
</div>
</div>
<div className="panel">
<div className="panel-head">
<h3 className="grow">Sync Sections</h3>
</div>
<div className="panel-body col" style={{ gap: 12 }}>
<div className="field">
<label className="field-label">Source user</label>
<select className="input" value={syncSourceId} onChange={(e) => setSyncSourceId(e.target.value)}>
<option value="">Choose source</option>
{users.filter((user) => user.sections?.length).map((user) => (
<option key={String(user.id)} value={String(user.id)}>{user.name}</option>
))}
</select>
</div>
<div className="field">
<label className="field-label">Mode</label>
<select className="input" value={syncMode} onChange={(e) => setSyncMode(e.target.value as "append" | "replace")}>
<option value="append">Append</option>
<option value="replace">Replace</option>
</select>
</div>
<div className="field">
<label className="field-label">Targets</label>
<div style={{ maxHeight: 180, overflow: "auto", display: "grid", gap: 8 }}>
{users.filter((user) => String(user.id) !== syncSourceId).map((user) => (
<label key={String(user.id)} className="chip" style={{ justifyContent: "flex-start", cursor: "pointer" }}>
<input
type="checkbox"
checked={syncTargetIds.includes(String(user.id))}
onChange={(e) =>
setSyncTargetIds((current) =>
e.target.checked ? [...current, String(user.id)] : current.filter((id) => id !== String(user.id))
)
}
/>{" "}
{user.name}
</label>
))}
</div>
</div>
<button className="btn btn-sm" onClick={performSync} disabled={!syncSourceId || !syncTargetIds.length}>
<IconCheck /> Sync sections
</button>
</div>
</div>
</div>
<div className="col">
{!selectedSection ? (
<Empty icon={<IconLayers />}>Select a section to edit.</Empty>
) : (
<>
<div className="panel">
<div className="panel-head">
<h3 className="grow">{selectedSection.CustomName || selectedSection.Name || "Section"}</h3>
<button className="btn btn-sm" onClick={() => moveSection(-1)} disabled={selectedSectionIndex === 0}>Up</button>
<button className="btn btn-sm" onClick={() => moveSection(1)} disabled={selectedSectionIndex >= selectedUser.sections.length - 1}>Down</button>
<button className="btn btn-sm btn-danger" onClick={removeSection}><IconTrash /> Remove</button>
</div>
<div className="panel-body col" style={{ gap: 14 }}>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", gap: 14 }}>
<div className="field">
<label className="field-label">Name</label>
<input className="input" value={selectedSection.Name || ""} onChange={(e) => updateSelectedSection((section) => ({ ...section, Name: e.target.value }))} />
</div>
<div className="field">
<label className="field-label">Custom name</label>
<input className="input" value={selectedSection.CustomName || ""} onChange={(e) => updateSelectedSection((section) => ({ ...section, CustomName: e.target.value }))} />
</div>
<div className="field">
<label className="field-label">Section type</label>
<select className="input" value={selectedSection.SectionType || ""} onChange={(e) => updateSelectedSection((section) => ({ ...section, SectionType: e.target.value }))}>
{enums.section_types.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
</select>
</div>
<div className="field">
<label className="field-label">Image type</label>
<select className="input" value={selectedSection.ImageType || ""} onChange={(e) => updateSelectedSection((section) => ({ ...section, ImageType: e.target.value }))}>
{enums.image_types.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
</select>
</div>
<div className="field">
<label className="field-label">Collection type</label>
<select className="input" value={selectedSection.CollectionType || ""} onChange={(e) => updateSelectedSection((section) => ({ ...section, CollectionType: e.target.value }))}>
{enums.collection_types.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
</select>
</div>
<div className="field">
<label className="field-label">Sort by</label>
<select className="input" value={selectedSection.SortBy || ""} onChange={(e) => updateSelectedSection((section) => ({ ...section, SortBy: e.target.value }))}>
{enums.sort_options.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
</select>
</div>
</div>
<div className="field">
<label className="field-label">Item types</label>
<div className="row wrap" style={{ gap: 8 }}>
{enums.item_types.map((itemType) => (
<label key={itemType} className="chip" style={{ cursor: "pointer" }}>
<input type="checkbox" checked={(selectedSection.ItemTypes || []).includes(itemType)} onChange={() => toggleItemType(itemType)} /> {itemType}
</label>
))}
</div>
</div>
<div className="row gap-sm" style={{ flexWrap: "wrap" }}>
<label className="chip" style={{ cursor: "pointer" }}>
<input
type="checkbox"
checked={!!selectedSection.IncludeNextUpInResume}
onChange={(e) => updateSelectedSection((section) => ({ ...section, IncludeNextUpInResume: e.target.checked }))}
/>{" "}
Include Next Up in resume
</label>
<label className="chip" style={{ cursor: "pointer" }}>
<input
type="checkbox"
checked={!!selectedSection.Query?.IsPlayed}
onChange={(e) => updateSelectedSection((section) => ({ ...section, Query: { ...(section.Query || {}), IsPlayed: e.target.checked } }))}
/>{" "}
Played items only
</label>
</div>
{selectedSection.SectionType === "boxset" ? (
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", gap: 14 }}>
<div className="field">
<label className="field-label">Parent item name</label>
<input
className="input"
value={selectedSection.ParentItem?.Name || ""}
onChange={(e) =>
updateSelectedSection((section) => ({
...section,
ParentItem: { ...(section.ParentItem || {}), Name: e.target.value },
}))
}
/>
</div>
<div className="field">
<label className="field-label">Parent item / collection ID</label>
<input
className="input"
value={selectedSection.ParentId || selectedSection.ParentItem?.Id || ""}
onChange={(e) =>
updateSelectedSection((section) => ({
...section,
ParentId: e.target.value,
ParentItem: { ...(section.ParentItem || {}), Id: e.target.value },
}))
}
/>
</div>
</div>
) : null}
<div className="field">
<label className="field-label">Section JSON</label>
<textarea
className="input"
value={sectionJson}
onChange={(e) => setSectionJson(e.target.value)}
style={{ minHeight: 300, fontFamily: "ui-monospace, SFMono-Regular, monospace", resize: "vertical" }}
/>
<div className="row gap-sm" style={{ marginTop: 10 }}>
<button className="btn btn-sm" onClick={applySectionJson}><IconCheck /> Apply JSON</button>
</div>
</div>
</div>
</div>
<div className="panel">
<div className="panel-head">
<h3 className="grow">Emby Context</h3>
{contextBusy ? <span className="spinner" /> : userContext?.source ? <span className="badge">{userContext.source}</span> : null}
</div>
<div className="panel-body col" style={{ gap: 14 }}>
{userContext?.message ? <p className="hint" style={{ margin: 0 }}>{userContext.message}</p> : null}
<div>
<div className="section-label" style={{ margin: "0 0 8px" }}>Libraries / views</div>
<div className="row wrap" style={{ gap: 8 }}>
{userContext?.views?.length ? userContext.views.map((view) => <span key={view.id} className="badge">{view.name}</span>) : <span className="hint">No views loaded.</span>}
</div>
</div>
<div>
<div className="section-label" style={{ margin: "0 0 8px" }}>Excluded folders in this section</div>
<div className="row wrap" style={{ gap: 8 }}>
{Object.entries(userContext?.excludedFolderLookup || {}).length ? Object.entries(userContext?.excludedFolderLookup || {}).map(([id, item]) => (
<span key={id} className="badge">{item.name}</span>
)) : <span className="hint">No excluded folder metadata for this section.</span>}
</div>
</div>
<div>
<div className="section-label" style={{ margin: "0 0 8px" }}>Recently played</div>
{userContext?.recentlyPlayed?.length ? (
<div style={{ maxHeight: 220, overflow: "auto" }}>
<table className="data-table">
<thead>
<tr>
<th>Item</th>
<th>Type</th>
<th>Played</th>
</tr>
</thead>
<tbody>
{userContext.recentlyPlayed.slice(0, 20).map((item) => (
<tr key={item.id}>
<td>
<div className="cell-strong">{item.name}</div>
{item.seriesName ? <div className="cell-sub">{item.seriesName}</div> : null}
</td>
<td>{item.type}</td>
<td>{item.datePlayed ? formatNZ(item.datePlayed) : "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<span className="hint">No recently played items loaded.</span>
)}
</div>
</div>
</div>
</>
)}
</div>
</div>
</>
)}
{validation ? (
<div className="panel">
<div className="panel-head">
<h3 className="grow">Database Validation</h3>
</div>
<div className="panel-body">
<div className="mini-grid">
<div className="mini-stat"><div className="stat-meta"><div className="stat-value">{validation.userCount}</div><div className="stat-label">Users</div></div></div>
<div className="mini-stat"><div className="stat-meta"><div className="stat-value">{validation.settingsCount}</div><div className="stat-label">Settings rows</div></div></div>
<div className="mini-stat"><div className="stat-meta"><div className="stat-value">{validation.mismatchedUsers}</div><div className="stat-label">Mismatched users</div></div></div>
<div className="mini-stat"><div className="stat-meta"><div className="stat-value">{changes.length}</div><div className="stat-label">Pending changes</div></div></div>
</div>
</div>
</div>
) : null}
{sqlPreview ? (
<div className="panel">
<div className="panel-head">
<h3 className="grow">SQL Preview</h3>
</div>
<div className="panel-body">
<div className="console" style={{ maxHeight: 360 }}>
<pre style={{ margin: 0, whiteSpace: "pre-wrap" }}>{sqlPreview}</pre>
</div>
</div>
</div>
) : null}
</div>
</div>
</>
);
}
@@ -145,10 +145,7 @@ export default function CollectionCompleteness() {
return (
<>
<div className="row between" style={{ alignItems: "flex-start" }}>
<PageHead title="Music Collection Completeness" icon={<IconDisc />}>
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.
</PageHead>
<PageHead title="Music Collection Completeness" icon={<IconDisc />} />
<div className="row gap-sm">
<button className="btn" onClick={startScan} disabled={o?.scan_running}>
{o?.scan_running ? <span className="spinner" /> : <IconRefresh />} Scan library
+209 -135
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from "react";
import { apiGet, apiPost } from "../../api";
import { PageHead, StatCard, Empty, Loading } from "../../components/ui";
import { IconDisc, IconFolder, IconImage, IconRefresh, IconTrash, IconWand, IconPlay } from "../../components/icons";
import { useEffect, useRef, useState } from "react";
import { apiGet, streamNDJSON } from "../../api";
import { PageHead, Empty } from "../../components/ui";
import { IconDisc, IconFolder, IconImage, IconRefresh, IconTrash, IconWand, IconPlay, IconCheck, IconCalendar } from "../../components/icons";
import { useToast } from "../../lib/toast";
interface Album {
@@ -12,19 +12,9 @@ interface Album {
year: string | null;
track_count: number;
has_cover: boolean;
suggested_folder: string | null;
needs_folder_rename: boolean;
extra_file_count: number;
}
interface Scan {
root: string;
exists: boolean;
album_count?: number;
missing_cover_count?: number;
needs_rename_count?: number;
extra_file_count?: number;
albums: Album[];
}
interface Action {
level: string;
action: string;
@@ -32,78 +22,107 @@ interface Action {
}
const MODES = [
{ key: "covers", label: "Fetch covers", desc: "Download missing cover.jpg from Cover Art Archive", icon: <IconImage /> },
{ key: "folder_cleanup", label: "Folder cleanup", desc: "Normalize folders to 'YEAR - Album'", icon: <IconFolder /> },
{ key: "folder_cleanup", label: "Folder rename", desc: "Normalize album folders to 'YEAR - Album'", icon: <IconFolder /> },
{ key: "rename", label: "Rename tracks", desc: "Rename audio files to 'NN - Title'", icon: <IconDisc /> },
{ key: "covers", label: "Fetch covers", desc: "Download missing cover.jpg (Cover Art Archive)", icon: <IconImage /> },
{ key: "lyrics", label: "Fetch lyrics", desc: "Download .lrc / .txt sidecars (LRCLIB)", icon: <IconWand /> },
{ key: "file_cleanup", label: "File cleanup", desc: "Remove non-audio / non-art files", icon: <IconTrash /> },
{ key: "lyrics", label: "Fetch lyrics", desc: "Download .lrc / .txt sidecars from LRCLIB", icon: <IconWand /> },
] as const;
type ModeKey = (typeof MODES)[number]["key"];
export default function CoverManager() {
const toast = useToast();
const [scan, setScan] = useState<Scan | null>(null);
const [loading, setLoading] = useState(true);
const [running, setRunning] = useState(false);
const [dryRun, setDryRun] = useState(true);
const [root, setRoot] = useState<{ path: string; available: boolean } | null>(null);
const [modes, setModes] = useState<Record<ModeKey, boolean>>({
folder_cleanup: true,
rename: true,
covers: true,
folder_cleanup: false,
rename: false,
file_cleanup: false,
lyrics: false,
file_cleanup: false,
});
const [actions, setActions] = useState<Action[] | null>(null);
const [recentOnly, setRecentOnly] = useState(false);
const [dryRun, setDryRun] = useState(true);
const [running, setRunning] = useState(false);
const [actions, setActions] = useState<Action[]>([]);
function refresh() {
setLoading(true);
apiGet<Scan>("/api/music/scan")
.then(setScan)
.catch((e) => toast(e.message, "err"))
.finally(() => setLoading(false));
}
useEffect(refresh, []); // eslint-disable-line react-hooks/exhaustive-deps
const [albums, setAlbums] = useState<Album[]>([]);
const [scanning, setScanning] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
async function run() {
const runAbort = useRef<AbortController | null>(null);
const scanAbort = useRef<AbortController | null>(null);
const logRef = useRef<HTMLDivElement>(null);
useEffect(() => {
apiGet("/api/config")
.then((c) => setRoot({ path: c.music.root, available: c.music.available }))
.catch(() => setRoot(null));
return () => {
runAbort.current?.abort();
scanAbort.current?.abort();
};
}, []);
useEffect(() => {
logRef.current?.scrollTo({ top: logRef.current.scrollHeight });
}, [actions]);
const anyMode = Object.values(modes).some(Boolean);
async function run(albumPaths?: string[]) {
if (!anyMode) return;
if (!dryRun) {
const ok = window.confirm(
"Apply mode is ON. This will permanently rename folders, rename files, delete extras and download files on your music share. Continue?"
);
if (!ok) return;
const scope = albumPaths ? "the selected album(s)" : recentOnly ? "recently-changed albums" : "your whole music library";
if (!window.confirm(`Apply mode is ON. This will permanently modify files in ${scope}. Continue?`)) return;
}
setRunning(true);
setActions(null);
setActions([]);
const ctrl = new AbortController();
runAbort.current = ctrl;
try {
const res = await apiPost<{ actions: Action[]; dry_run: boolean }>("/api/music/process", {
...modes,
dry_run: dryRun,
await streamNDJSON("/api/music/process/stream", {
method: "POST",
body: { ...modes, dry_run: dryRun, recent_only: albumPaths ? false : recentOnly, album_paths: albumPaths || null },
signal: ctrl.signal,
onMessage: (msg: Action) => setActions((prev) => [...prev, msg]),
});
setActions(res.actions);
toast(dryRun ? "Dry run complete" : "Changes applied", dryRun ? "info" : "ok");
if (!dryRun) refresh();
} catch (e: any) {
toast(e.message, "err");
if (e?.name !== "AbortError") toast(e.message || "Run failed", "err");
} finally {
setRunning(false);
runAbort.current = null;
}
}
if (loading) return <Loading label="Scanning music library…" />;
async function previewScan() {
setScanning(true);
setAlbums([]);
setSelected(new Set());
const ctrl = new AbortController();
scanAbort.current = ctrl;
try {
await streamNDJSON("/api/music/scan/stream", {
signal: ctrl.signal,
onMessage: (msg) => {
if (msg.type === "album") setAlbums((prev) => [...prev, msg.album]);
else if (msg.type === "error") toast(msg.message, "err");
},
});
} catch (e: any) {
if (e?.name !== "AbortError") toast(e.message || "Scan failed", "err");
} finally {
setScanning(false);
scanAbort.current = null;
}
}
if (!scan?.exists) {
return (
<>
<PageHead title="Cover Manager">Maintain your local music library.</PageHead>
<div className="panel">
<Empty icon={<IconFolder />}>
Music root not found: <code>{scan?.root}</code>
<br />
Set <code>MUSIC_ROOT</code> and mount the share into the container.
</Empty>
</div>
</>
);
function toggleSelect(path: string) {
setSelected((s) => {
const n = new Set(s);
n.has(path) ? n.delete(path) : n.add(path);
return n;
});
}
const logClass = (a: Action) =>
@@ -111,23 +130,22 @@ export default function CoverManager() {
return (
<>
<PageHead title="Cover Manager">
Clean album folders, rename tracks and fetch missing covers across <code>{scan.root}</code>. Runs in dry-run
mode by default nothing changes until you turn that off.
</PageHead>
<PageHead title="Library Cleanup" icon={<IconWand />} />
<div className="stat-row" style={{ marginBottom: 22 }}>
<StatCard icon={<IconDisc />} value={scan.album_count ?? 0} label="Albums" />
<StatCard icon={<IconImage />} value={scan.missing_cover_count ?? 0} label="Missing covers" />
<StatCard icon={<IconFolder />} value={scan.needs_rename_count ?? 0} label="Folders to rename" />
<StatCard icon={<IconTrash />} value={scan.extra_file_count ?? 0} label="Extra files" />
</div>
{root && !root.available && (
<div className="panel" style={{ marginBottom: 18 }}>
<Empty icon={<IconFolder />}>
Music root not mounted: <code>{root.path}</code>. Set <code>MUSIC_ROOT</code> and mount the share.
</Empty>
</div>
)}
<div className="workbench" style={{ gridTemplateColumns: "340px 1fr" }}>
<div className="workbench" style={{ gridTemplateColumns: "360px 1fr" }}>
{/* controls */}
<div className="col">
<div className="panel">
<div className="panel-head">
<h3>Maintenance modes</h3>
<h3>What to do</h3>
</div>
<div className="panel-body col" style={{ gap: 10 }}>
{MODES.map((m) => (
@@ -149,12 +167,23 @@ export default function CoverManager() {
</span>
</label>
))}
<label className={`chip ${recentOnly ? "active" : ""}`} style={{ justifyContent: "flex-start", padding: "11px 13px", cursor: "pointer" }}>
<input type="checkbox" checked={recentOnly} onChange={(e) => setRecentOnly(e.target.checked)} style={{ marginRight: 4 }} />
<span style={{ flexShrink: 0 }}>
<IconCalendar />
</span>
<span style={{ textAlign: "left" }}>
<div style={{ fontWeight: 600, color: "var(--text)" }}>Only recent</div>
<div className="hint">Only albums changed in the last 2 hours</div>
</span>
</label>
</div>
</div>
<div className="panel">
<div className="panel-body col">
<label className={`chip ${!dryRun ? "" : "active"}`} style={{ justifyContent: "space-between", cursor: "pointer" }}>
<label className={`chip ${dryRun ? "active" : ""}`} style={{ justifyContent: "space-between", cursor: "pointer" }}>
<span>
<div style={{ fontWeight: 700, color: dryRun ? "var(--accent-h)" : "var(--red)" }}>
{dryRun ? "Dry run (safe)" : "Apply changes (live)"}
@@ -163,81 +192,126 @@ export default function CoverManager() {
</span>
<input type="checkbox" checked={!dryRun} onChange={(e) => setDryRun(!e.target.checked)} />
</label>
<button className={`btn ${dryRun ? "btn-primary" : "btn-danger"} btn-block`} onClick={run} disabled={running}>
{running ? <span className="spinner" /> : <IconPlay />}
{running ? "Working…" : dryRun ? "Preview changes" : "Apply now"}
</button>
<button className="btn btn-block" onClick={refresh} disabled={running}>
<IconRefresh /> Rescan library
</button>
{running ? (
<button className="btn btn-danger btn-block" onClick={() => runAbort.current?.abort()}>
<span className="spinner" /> Stop
</button>
) : (
<button
className={`btn ${dryRun ? "btn-primary" : "btn-danger"} btn-block`}
onClick={() => run()}
disabled={!anyMode || (root ? !root.available : false)}
>
<IconPlay /> {dryRun ? "Preview run" : "Run now"}
</button>
)}
{selected.size > 0 && !running && (
<button className="btn btn-block" onClick={() => run([...selected])} disabled={!anyMode}>
<IconCheck /> Run on {selected.size} selected
</button>
)}
<p className="hint">
{dryRun
? "Preview shows exactly what would change — nothing is modified."
: "Live mode renames, deletes and downloads immediately."}
</p>
</div>
</div>
</div>
{/* output */}
<div className="col">
{actions && (
<div className="panel">
<div className="panel-head">
<h3>{scan && actions ? "Result" : ""} Action log</h3>
<span className="sub">{actions.length} entries</span>
</div>
<div className="panel-body">
<div className="console">
{actions.length === 0 ? (
<span className="dim">Nothing to do.</span>
) : (
actions.map((a, i) => (
<div className="log-line" key={i}>
<span className={`log-tag ${logClass(a)}`}>{a.level === "dry" ? "plan" : a.level}</span>
<span>{a.message}</span>
</div>
))
)}
</div>
<div className="panel">
<div className="panel-head">
<h3 className="grow">Activity</h3>
<span className="sub">
{actions.length} entries{running ? " · running…" : ""}
</span>
</div>
<div className="panel-body">
<div className="console" ref={logRef} style={{ minHeight: 200 }}>
{actions.length === 0 ? (
<span className="dim">Pick what to do on the left, then Run. Output streams here as it happens.</span>
) : (
actions.map((a, i) => (
<div className="log-line" key={i}>
<span className={`log-tag ${logClass(a)}`}>{a.level === "dry" ? "plan" : a.level}</span>
<span>{a.message}</span>
</div>
))
)}
</div>
</div>
)}
</div>
<div className="panel">
<div className="panel-head">
<h3>Albums</h3>
<span className="sub">{scan.albums.length}</span>
<h3 className="grow">Library preview</h3>
<span className="sub">{albums.length} albums</span>
{scanning ? (
<button className="btn btn-sm btn-danger" onClick={() => scanAbort.current?.abort()}>
<span className="spinner" /> Stop
</button>
) : (
<button className="btn btn-sm" onClick={previewScan} disabled={root ? !root.available : false}>
<IconRefresh /> Scan
</button>
)}
{albums.length > 0 && (
<button
className="btn btn-sm"
onClick={() => setSelected(selected.size === albums.length ? new Set() : new Set(albums.map((a) => a.path)))}
>
{selected.size === albums.length ? "Clear" : "Select all"}
</button>
)}
</div>
<div style={{ maxHeight: 520, overflow: "auto" }}>
<table className="data-table">
<thead>
<tr>
<th>Album</th>
<th>Year</th>
<th>Tracks</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{scan.albums.map((a) => (
<tr key={a.path}>
<td>
<div className="cell-strong">{a.album || a.folder_name}</div>
<div className="cell-sub">{a.artist}</div>
</td>
<td className="mono">{a.year || "—"}</td>
<td className="mono">{a.track_count}</td>
<td>
<div className="row wrap" style={{ gap: 6 }}>
{a.has_cover ? (
<span className="badge badge-ok">cover</span>
) : (
<span className="badge badge-warn">no cover</span>
)}
{a.needs_folder_rename && <span className="badge badge-accent">rename</span>}
{a.extra_file_count > 0 && <span className="badge">{a.extra_file_count} extra</span>}
</div>
</td>
{albums.length === 0 ? (
<Empty icon={<IconDisc />}>{scanning ? "Scanning…" : "Optional: scan to preview albums and run actions on specific ones."}</Empty>
) : (
<div style={{ maxHeight: 460, overflow: "auto" }}>
<table className="data-table">
<thead>
<tr>
<th style={{ width: 30 }}></th>
<th>Album</th>
<th>Year</th>
<th>Tracks</th>
<th>Status</th>
<th style={{ textAlign: "right" }}>Action</th>
</tr>
))}
</tbody>
</table>
</div>
</thead>
<tbody>
{albums.map((a) => (
<tr key={a.path}>
<td>
<input type="checkbox" checked={selected.has(a.path)} onChange={() => toggleSelect(a.path)} />
</td>
<td>
<div className="cell-strong">{a.album || a.folder_name}</div>
<div className="cell-sub">{a.artist}</div>
</td>
<td className="mono">{a.year || "—"}</td>
<td className="mono">{a.track_count}</td>
<td>
<div className="row wrap" style={{ gap: 6 }}>
{a.has_cover ? <span className="badge badge-ok">cover</span> : <span className="badge badge-warn">no cover</span>}
{a.needs_folder_rename && <span className="badge badge-accent">rename</span>}
{a.extra_file_count > 0 && <span className="badge">{a.extra_file_count} extra</span>}
</div>
</td>
<td style={{ textAlign: "right" }}>
<button className="btn btn-sm" disabled={running || !anyMode} onClick={() => run([a.path])} title="Run selected modes on this album">
<IconWand /> Fix
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
+3 -3
View File
@@ -74,7 +74,7 @@ export default function Library() {
if (status && !status.configured) {
return (
<>
<PageHead title="Music Library">Browse your Navidrome library.</PageHead>
<PageHead title="Music Library" icon={<IconDisc />} />
<div className="panel">
<Empty icon={<IconMusic />}>
Navidrome is not configured. Set <code>NAVIDROME_URL</code>, <code>NAVIDROME_USER</code> and{" "}
@@ -87,7 +87,7 @@ export default function Library() {
if (status && status.configured && !status.connected) {
return (
<>
<PageHead title="Music Library">Browse your Navidrome library.</PageHead>
<PageHead title="Music Library" icon={<IconDisc />} />
<div className="panel">
<Empty icon={<IconMusic />}>Could not connect to Navidrome. {status.error}</Empty>
</div>
@@ -97,7 +97,7 @@ export default function Library() {
return (
<>
<PageHead title="Music Library">Browse artists and albums served by your Navidrome instance.</PageHead>
<PageHead title="Music Library" icon={<IconDisc />} />
<div className="row between wrap" style={{ marginBottom: 18, gap: 12 }}>
<div className="seg">
+484
View File
@@ -0,0 +1,484 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { apiGet, apiPost, streamNDJSON } from "../../api";
import { PageHead, Empty } from "../../components/ui";
import { IconMusic, IconDisc, IconTrash, IconPlay, IconCalendar, IconCheck, IconChevron, IconWand } from "../../components/icons";
import { useToast } from "../../lib/toast";
interface Override {
artist: string;
genre: string;
}
interface Action {
level: string;
action: string;
message: string;
file?: string;
group?: string;
subgroup?: string;
junk?: string[];
track?: string | null;
genre?: string | null;
path?: string;
}
const MODES = [
{
key: "genres",
label: "Fix genres",
desc: "Look each album up on MusicBrainz and write one canonical genre to every track",
icon: <IconMusic />,
},
{
key: "strip_junk",
label: "Strip junk tags",
desc: "Remove comment, encoder/tool, URL/purchase and embedded-lyrics frames",
icon: <IconTrash />,
},
{
key: "normalize_tracks",
label: "Normalize track numbers",
desc: "Drop the '/total' suffix and zero-pad track/disc tags (1/12 → 01)",
icon: <IconDisc />,
},
] as const;
type ModeKey = (typeof MODES)[number]["key"];
export default function Metadata() {
const toast = useToast();
const [root, setRoot] = useState<{ path: string; available: boolean } | null>(null);
const [modes, setModes] = useState<Record<ModeKey, boolean>>({
genres: true,
strip_junk: true,
normalize_tracks: true,
});
const [recentOnly, setRecentOnly] = useState(false);
const [dryRun, setDryRun] = useState(true);
const [running, setRunning] = useState(false);
const [actions, setActions] = useState<Action[]>([]);
const [showLog, setShowLog] = useState(false);
const [overrides, setOverrides] = useState<Override[]>([]);
const [newArtist, setNewArtist] = useState("");
const [newGenre, setNewGenre] = useState("");
const runAbort = useRef<AbortController | null>(null);
const logRef = useRef<HTMLDivElement>(null);
function loadOverrides() {
apiGet<{ overrides: Override[] }>("/api/music/metadata/overrides")
.then((r) => setOverrides(r.overrides || []))
.catch(() => {});
}
useEffect(() => {
apiGet("/api/config")
.then((c) => setRoot({ path: c.music.root, available: c.music.available }))
.catch(() => setRoot(null));
loadOverrides();
return () => runAbort.current?.abort();
}, []);
async function saveOverride(artist: string, genre: string) {
if (!artist.trim() || !genre.trim()) return;
try {
await apiPost("/api/music/metadata/overrides", { artist: artist.trim(), genre: genre.trim() });
loadOverrides();
toast(`Pinned ${artist.trim()}${genre.trim()}`, "ok");
} catch (e: any) {
toast(e.message || "Could not save override", "err");
}
}
async function removeOverride(artist: string) {
try {
await apiPost("/api/music/metadata/overrides/delete", { artist });
loadOverrides();
} catch (e: any) {
toast(e.message || "Could not remove override", "err");
}
}
async function addOverride() {
await saveOverride(newArtist, newGenre);
setNewArtist("");
setNewGenre("");
}
useEffect(() => {
if (showLog) logRef.current?.scrollTo({ top: logRef.current.scrollHeight });
}, [actions, showLog]);
const anyMode = Object.values(modes).some(Boolean);
// ── derive table rows, grouped by album, plus live stats ──────────────────
const rows = useMemo(() => actions.filter((a) => a.action === "tag" && a.file), [actions]);
const statusLog = useMemo(() => actions.filter((a) => a.action !== "tag"), [actions]);
const stats = useMemo(() => {
let junk = 0;
let tracks = 0;
let genres = 0;
for (const r of rows) {
junk += r.junk?.length || 0;
if (r.track) tracks += 1;
if (r.genre) genres += 1;
}
return { files: rows.length, junk, tracks, genres };
}, [rows]);
const groups = useMemo(() => {
const out: { key: string; group: string; subgroup: string; rows: Action[] }[] = [];
const index = new Map<string, number>();
for (const r of rows) {
const key = `${r.subgroup || ""}//${r.group || ""}`;
let i = index.get(key);
if (i === undefined) {
i = out.length;
index.set(key, i);
out.push({ key, group: r.group || "Unknown album", subgroup: r.subgroup || "", rows: [] });
}
out[i].rows.push(r);
}
return out;
}, [rows]);
const overriddenKeys = useMemo(
() => new Set(overrides.map((o) => o.artist.trim().toLowerCase())),
[overrides]
);
const lastStatus = statusLog.length ? statusLog[statusLog.length - 1].message : "";
async function run() {
if (!anyMode) return;
if (!dryRun) {
const scope = recentOnly ? "recently-changed albums" : "your whole music library";
if (!window.confirm(`Apply mode is ON. This will permanently rewrite tags in ${scope}. Continue?`)) return;
}
setRunning(true);
setActions([]);
const ctrl = new AbortController();
runAbort.current = ctrl;
try {
await streamNDJSON("/api/music/metadata/process/stream", {
method: "POST",
body: { ...modes, dry_run: dryRun, recent_only: recentOnly },
signal: ctrl.signal,
onMessage: (msg: Action) => setActions((prev) => [...prev, msg]),
});
toast(dryRun ? "Dry run complete" : "Tags updated", dryRun ? "info" : "ok");
} catch (e: any) {
if (e?.name !== "AbortError") toast(e.message || "Run failed", "err");
} finally {
setRunning(false);
runAbort.current = null;
}
}
const logClass = (a: Action) =>
({ ok: "log-ok", dry: "log-dry", skip: "log-skip", warn: "log-warn", info: "log-info" }[a.level] || "log-info");
return (
<>
<PageHead title="Metadata Editor" icon={<IconMusic />} />
{root && !root.available && (
<div className="panel" style={{ marginBottom: 18 }}>
<Empty icon={<IconMusic />}>
Music root not mounted: <code>{root.path}</code>. Set <code>MUSIC_ROOT</code> and mount the share.
</Empty>
</div>
)}
<div className="workbench" style={{ gridTemplateColumns: "330px 1fr", alignItems: "start" }}>
{/* ── controls ── */}
<div className="col">
<div className="panel">
<div className="panel-head">
<h3>What to do</h3>
</div>
<div className="panel-body col" style={{ gap: 10 }}>
{MODES.map((m) => (
<label
key={m.key}
className={`chip ${modes[m.key] ? "active" : ""}`}
style={{ justifyContent: "flex-start", padding: "11px 13px", cursor: "pointer" }}
>
<input
type="checkbox"
checked={modes[m.key]}
onChange={(e) => setModes((s) => ({ ...s, [m.key]: e.target.checked }))}
style={{ marginRight: 4 }}
/>
<span style={{ flexShrink: 0 }}>{m.icon}</span>
<span style={{ textAlign: "left" }}>
<div style={{ fontWeight: 600, color: "var(--text)" }}>{m.label}</div>
<div className="hint">{m.desc}</div>
</span>
</label>
))}
<label className={`chip ${recentOnly ? "active" : ""}`} style={{ justifyContent: "flex-start", padding: "11px 13px", cursor: "pointer" }}>
<input type="checkbox" checked={recentOnly} onChange={(e) => setRecentOnly(e.target.checked)} style={{ marginRight: 4 }} />
<span style={{ flexShrink: 0 }}>
<IconCalendar />
</span>
<span style={{ textAlign: "left" }}>
<div style={{ fontWeight: 600, color: "var(--text)" }}>Only recent</div>
<div className="hint">Only albums changed in the last 2 hours</div>
</span>
</label>
</div>
</div>
<div className="panel">
<div className="panel-body col">
<label className={`chip ${dryRun ? "active" : ""}`} style={{ justifyContent: "space-between", cursor: "pointer" }}>
<span>
<div style={{ fontWeight: 700, color: dryRun ? "var(--accent-h)" : "var(--red)" }}>
{dryRun ? "Dry run (safe)" : "Apply changes (live)"}
</div>
<div className="hint">{dryRun ? "Preview only — no tags written" : "Will rewrite tags in your files"}</div>
</span>
<input type="checkbox" checked={!dryRun} onChange={(e) => setDryRun(!e.target.checked)} />
</label>
{running ? (
<button className="btn btn-danger btn-block" onClick={() => runAbort.current?.abort()}>
<span className="spinner" /> Stop
</button>
) : (
<button
className={`btn ${dryRun ? "btn-primary" : "btn-danger"} btn-block`}
onClick={() => run()}
disabled={!anyMode || (root ? !root.available : false)}
>
<IconPlay /> {dryRun ? "Preview run" : "Run now"}
</button>
)}
<p className="hint">
{dryRun
? "Preview shows exactly which tags would change — nothing is written."
: "Live mode rewrites tags immediately. Genre lookups query MusicBrainz (~1 album/sec)."}
</p>
</div>
</div>
<div className="panel">
<div className="panel-head">
<h3 className="grow">Genre overrides</h3>
<span className="sub">{overrides.length}</span>
</div>
<div className="panel-body col" style={{ gap: 8 }}>
<p className="hint">
Force a genre for an artist overrides always win over MusicBrainz, so you have full control.
</p>
{overrides.length > 0 && (
<div className="col" style={{ gap: 6 }}>
{overrides.map((o) => (
<div key={o.artist} className="row" style={{ gap: 8, alignItems: "center" }}>
<span
className="cell-strong"
style={{ flex: "1 1 auto", minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
title={o.artist}
>
{o.artist}
</span>
<span className="badge badge-ok">{o.genre}</span>
<button className="btn btn-sm" title="Remove override" onClick={() => removeOverride(o.artist)}>
<IconTrash />
</button>
</div>
))}
</div>
)}
<div className="row" style={{ gap: 6 }}>
<input
className="input"
placeholder="Artist"
value={newArtist}
onChange={(e) => setNewArtist(e.target.value)}
style={{ flex: 1, minWidth: 0 }}
/>
<input
className="input"
placeholder="Genre"
value={newGenre}
onChange={(e) => setNewGenre(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addOverride()}
style={{ flex: 1, minWidth: 0 }}
/>
</div>
<button className="btn btn-sm btn-block" onClick={addOverride} disabled={!newArtist.trim() || !newGenre.trim()}>
<IconCheck /> Add override
</button>
</div>
</div>
</div>
{/* ── results ── */}
<div className="col" style={{ minWidth: 0 }}>
<div className="stat-row" style={{ gridTemplateColumns: "repeat(4, 1fr)" }}>
<Tile icon={<IconCheck />} value={stats.files} label="Files changed" />
<Tile icon={<IconTrash />} value={stats.junk} label="Junk tags removed" />
<Tile icon={<IconDisc />} value={stats.tracks} label="Tracks renumbered" />
<Tile icon={<IconMusic />} value={stats.genres} label="Genres set" />
</div>
<div className="panel" style={{ display: "flex", flexDirection: "column", minHeight: 0 }}>
<div className="panel-head">
<h3 className="grow">{dryRun ? "Planned changes" : "Applied changes"}</h3>
<span className="sub">
{running ? (
<>
<span className="spinner" /> {lastStatus || "working…"}
</>
) : (
`${groups.length} album(s) · ${rows.length} file(s)`
)}
</span>
</div>
<div style={{ overflow: "auto", maxHeight: "calc(100vh - 430px)", minHeight: 340 }}>
{rows.length === 0 ? (
<Empty icon={<IconMusic />}>
{running ? "Scanning your library…" : "Pick what to do on the left, then Run. Per-file changes appear here as a table."}
</Empty>
) : (
<table className="data-table">
<thead>
<tr>
{["Track", "Junk stripped", "Track #", "Genre"].map((h, i) => (
<th
key={h}
style={{
position: "sticky",
top: 0,
zIndex: 1,
background: "var(--surface)",
width: i === 0 ? "40%" : undefined,
}}
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{groups.map((g) => (
<GroupBlock
key={g.key}
group={g}
overridden={overriddenKeys.has((g.subgroup || "").trim().toLowerCase())}
onPin={saveOverride}
/>
))}
</tbody>
</table>
)}
</div>
</div>
{statusLog.length > 0 && (
<div className="panel">
<button
className="panel-head"
onClick={() => setShowLog((s) => !s)}
style={{ width: "100%", background: "none", border: 0, cursor: "pointer", color: "inherit" }}
>
<h3 className="grow" style={{ textAlign: "left" }}>
Activity log
</h3>
<span className="sub">{statusLog.length} entries</span>
<IconChevron className={`nav-caret ${showLog ? "open" : ""}`} />
</button>
{showLog && (
<div className="panel-body">
<div className="console" ref={logRef} style={{ maxHeight: 240 }}>
{statusLog.map((a, i) => (
<div className="log-line" key={i}>
<span className={`log-tag ${logClass(a)}`}>{a.level === "dry" ? "plan" : a.level}</span>
<span>{a.message}</span>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
</div>
</>
);
}
function Tile({ icon, value, label }: { icon: JSX.Element; value: number; label: string }) {
return (
<div className="stat-card">
<div className="stat-icon">{icon}</div>
<div className="stat-meta">
<div className="stat-value">{value.toLocaleString()}</div>
<div className="stat-label">{label}</div>
</div>
</div>
);
}
function GroupBlock({
group,
overridden,
onPin,
}: {
group: { group: string; subgroup: string; rows: Action[] };
overridden: boolean;
onPin: (artist: string, genre: string) => void;
}) {
const groupGenre = group.rows.find((r) => r.genre)?.genre || null;
const canPin = !!(group.subgroup && groupGenre && !overridden);
return (
<>
<tr>
<td colSpan={4} style={{ background: "var(--surface3)", padding: "9px 14px" }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
<span className="cell-strong">{group.group}</span>
{group.subgroup && <span className="cell-sub">{group.subgroup}</span>}
{overridden && <span className="badge badge-accent">override</span>}
<span className="badge" style={{ marginLeft: "auto" }}>
{group.rows.length} file{group.rows.length === 1 ? "" : "s"}
</span>
{canPin && (
<button
className="btn btn-sm"
title={`Pin ${group.subgroup}${groupGenre} as a permanent override`}
onClick={() => onPin(group.subgroup, groupGenre as string)}
>
<IconWand /> Pin genre
</button>
)}
</div>
</td>
</tr>
{group.rows.map((r, i) => (
<tr key={r.path || `${group.group}-${i}`}>
<td>
<div className="cell-strong" style={{ wordBreak: "break-word" }}>
{r.file}
</div>
</td>
<td>
{r.junk && r.junk.length > 0 ? (
<div className="row wrap" style={{ gap: 6, alignItems: "center" }}>
<span className="badge badge-warn">{r.junk.length}</span>
<span className="cell-sub" title={r.junk.join(", ")} style={{ wordBreak: "break-word" }}>
{r.junk.slice(0, 4).join(", ")}
{r.junk.length > 4 ? ` +${r.junk.length - 4} more` : ""}
</span>
</div>
) : (
<span className="cell-sub"></span>
)}
</td>
<td>{r.track ? <span className="badge badge-accent mono">{r.track}</span> : <span className="cell-sub"></span>}</td>
<td>{r.genre ? <span className="badge badge-ok">{r.genre}</span> : <span className="cell-sub"></span>}</td>
</tr>
))}
</>
);
}
+281
View File
@@ -0,0 +1,281 @@
import { useEffect, useState } from "react";
import { apiGet } from "../../api";
import { Empty, Loading, PageHead, StatCard, fmtDuration, fmtNumber } from "../../components/ui";
import { IconCalendar, IconDisc, IconMusic, IconPlay, IconRefresh, IconUser } from "../../components/icons";
import { useToast } from "../../lib/toast";
interface Album {
id: string;
name: string;
artist: string;
year?: number;
song_count: number;
duration: number;
cover_url: string | null;
}
interface Song {
id: string;
title: string;
artist: string;
album: string;
year?: number;
duration: number;
play_count: number;
created?: string | null;
cover_url?: string | null;
}
interface Genre {
name: string;
song_count: number;
album_count: number;
}
interface ReportingData {
summary: {
artist_count: number;
album_count: number;
song_count: number;
genre_count: number;
library_tracks_scanned: number;
tracks_with_plays: number;
total_play_count: number;
favorite_song_count: number;
favorite_album_count: number;
favorite_artist_count: number;
now_playing_count: number;
scan_pages: number;
truncated: boolean;
};
top_tracks: Song[];
favorite_tracks: Song[];
recently_added_tracks: Song[];
top_albums: Album[];
recent_albums: Album[];
newest_albums: Album[];
top_genres: Genre[];
now_playing: Song[];
}
function AlbumStrip({ title, items }: { title: string; items: Album[] }) {
return (
<div className="panel">
<div className="panel-head">
<h3>{title}</h3>
<span className="sub">{items.length}</span>
</div>
{items.length === 0 ? (
<Empty icon={<IconDisc />}>No albums available.</Empty>
) : (
<div className="card-grid" style={{ gridTemplateColumns: "repeat(auto-fill, minmax(180px, 1fr))", padding: 18 }}>
{items.map((a) => (
<div key={a.id} className="media-card" style={{ cursor: "default" }}>
{a.cover_url ? (
<img className="media-cover" src={`${a.cover_url}?size=300`} loading="lazy" alt={a.name} />
) : (
<div className="media-cover" style={{ display: "grid", placeItems: "center" }}>
<IconDisc className="dim" />
</div>
)}
<div className="media-body">
<div className="media-title">{a.name}</div>
<div className="media-sub">
{a.artist}
{a.year ? ` · ${a.year}` : ""}
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}
function SongTable({ title, songs, secondary = "plays" }: { title: string; songs: Song[]; secondary?: "plays" | "added" }) {
return (
<div className="panel">
<div className="panel-head">
<h3>{title}</h3>
<span className="sub">{songs.length}</span>
</div>
{songs.length === 0 ? (
<Empty icon={<IconMusic />}>No tracks available.</Empty>
) : (
<div style={{ overflowX: "auto" }}>
<table className="data-table">
<thead>
<tr>
<th>Track</th>
<th>Artist</th>
<th>Album</th>
<th>Length</th>
<th>{secondary === "plays" ? "Plays" : "Added"}</th>
</tr>
</thead>
<tbody>
{songs.map((song) => (
<tr key={song.id}>
<td className="cell-strong">{song.title}</td>
<td>{song.artist || "—"}</td>
<td className="cell-sub">{song.album || "—"}</td>
<td className="mono">{fmtDuration(song.duration)}</td>
<td className="mono">{secondary === "plays" ? fmtNumber(song.play_count) : song.created || "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
export default function Reporting() {
const toast = useToast();
const [status, setStatus] = useState<{ connected: boolean; configured: boolean; error?: string } | null>(null);
const [data, setData] = useState<ReportingData | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
useEffect(() => {
apiGet("/api/navidrome/status").then(setStatus).catch(() => setStatus({ connected: false, configured: false }));
}, []);
async function load(refresh = false) {
refresh ? setRefreshing(true) : setLoading(true);
try {
const result = await apiGet<ReportingData>(`/api/navidrome/reporting${refresh ? "?refresh=true" : ""}`);
setData(result);
} catch (e: any) {
toast(e.message, "err");
setData(null);
} finally {
setLoading(false);
setRefreshing(false);
}
}
useEffect(() => {
if (status?.connected) load();
}, [status?.connected]); // eslint-disable-line react-hooks/exhaustive-deps
if (status && !status.configured) {
return (
<>
<PageHead title="Reporting" icon={<IconDisc />} />
<div className="panel">
<Empty icon={<IconMusic />}>Navidrome is not configured yet.</Empty>
</div>
</>
);
}
if (status && status.configured && !status.connected) {
return (
<>
<PageHead title="Reporting" icon={<IconDisc />} />
<div className="panel">
<Empty icon={<IconMusic />}>Could not connect to Navidrome. {status.error}</Empty>
</div>
</>
);
}
return (
<>
<div className="row between" style={{ alignItems: "flex-start" }}>
<PageHead title="Reporting" icon={<IconDisc />} />
<button className="btn btn-sm" onClick={() => load(true)} disabled={refreshing || loading}>
{refreshing ? <span className="spinner" /> : <IconRefresh />} Refresh
</button>
</div>
{loading && !data ? (
<Loading label="Loading Navidrome reporting…" />
) : !data ? (
<div className="panel">
<Empty icon={<IconMusic />}>No reporting data available.</Empty>
</div>
) : (
<>
<div className="stat-row" style={{ marginBottom: 18 }}>
<StatCard icon={<IconPlay />} value={fmtNumber(data.summary.total_play_count)} label="Total plays" />
<StatCard icon={<IconMusic />} value={fmtNumber(data.summary.tracks_with_plays)} label="Tracks with plays" />
<StatCard icon={<IconDisc />} value={fmtNumber(data.summary.favorite_song_count)} label="Starred songs" />
<StatCard icon={<IconUser />} value={fmtNumber(data.summary.now_playing_count)} label="Now playing" />
</div>
<div className="stat-row" style={{ marginBottom: 24 }}>
<StatCard icon={<IconUser />} value={fmtNumber(data.summary.artist_count)} label="Artists" />
<StatCard icon={<IconDisc />} value={fmtNumber(data.summary.album_count)} label="Albums" />
<StatCard icon={<IconMusic />} value={fmtNumber(data.summary.song_count)} label="Tracks" />
<StatCard icon={<IconCalendar />} value={fmtNumber(data.summary.library_tracks_scanned)} label="Tracks scanned" />
</div>
<div className="panel" style={{ marginBottom: 24 }}>
<div className="panel-head">
<h3>Scan notes</h3>
</div>
<div className="panel-body">
<div className="row gap-sm wrap">
<span className="badge">{fmtNumber(data.summary.scan_pages)} page(s) scanned</span>
{data.summary.truncated ? <span className="badge badge-warn">results truncated by safety cap</span> : <span className="badge badge-ok">full library sampled</span>}
<span className="badge">{fmtNumber(data.summary.favorite_album_count)} starred albums</span>
<span className="badge">{fmtNumber(data.summary.favorite_artist_count)} starred artists</span>
</div>
<p className="hint" style={{ marginTop: 12, marginBottom: 0 }}>
Play counts and starred state are specific to the authenticated Navidrome user because they come from the Subsonic-compatible API.
</p>
</div>
</div>
<div className="dash-split" style={{ marginBottom: 24 }}>
<SongTable title="Top Tracks" songs={data.top_tracks} />
<SongTable title="Starred Tracks" songs={data.favorite_tracks} />
</div>
<div className="dash-split" style={{ marginBottom: 24 }}>
<SongTable title="Recently Added Tracks" songs={data.recently_added_tracks} secondary="added" />
<div className="panel">
<div className="panel-head">
<h3>Top Genres</h3>
<span className="sub">{data.top_genres.length}</span>
</div>
<div className="panel-body">
{data.top_genres.length === 0 ? (
<Empty icon={<IconDisc />}>No genre data.</Empty>
) : (
data.top_genres.map((genre) => (
<div key={genre.name} className="genre-row">
<span className="genre-name">{genre.name}</span>
<span className="genre-bar">
<span
className="genre-bar-fill"
style={{
width: `${(genre.song_count / Math.max(...data.top_genres.map((g) => g.song_count), 1)) * 100}%`,
}}
/>
</span>
<span className="genre-count">{fmtNumber(genre.song_count)}</span>
</div>
))
)}
</div>
</div>
</div>
<AlbumStrip title="Most Played Albums" items={data.top_albums} />
<div style={{ height: 18 }} />
<AlbumStrip title="Recently Played Albums" items={data.recent_albums} />
<div style={{ height: 18 }} />
<AlbumStrip title="Newest Albums" items={data.newest_albums} />
<div style={{ height: 18 }} />
<SongTable title="Now Playing" songs={data.now_playing} secondary="added" />
</>
)}
</>
);
}
+599 -23
View File
@@ -98,6 +98,9 @@ a {
grid-template-columns: 244px 1fr;
min-height: 100vh;
}
.app.app-compact {
display: block;
}
.main {
min-width: 0;
display: flex;
@@ -113,6 +116,38 @@ a {
flex-direction: column;
background: var(--surface);
border-right: 1px solid var(--border);
z-index: 40;
}
.nav-backdrop {
display: none;
}
.app.app-compact .nav {
position: fixed;
left: 0;
top: 0;
width: min(320px, 86vw);
max-width: 100%;
transform: translateX(-100%);
transition: transform 220ms var(--ease-out);
box-shadow: var(--shadow-strong);
}
.app.app-compact .nav.mobile-open {
transform: translateX(0);
}
.app.app-compact .nav-backdrop {
position: fixed;
inset: 0;
border: 0;
background: rgba(6, 10, 14, 0.72);
opacity: 0;
pointer-events: none;
transition: opacity 180ms var(--ease-out);
z-index: 35;
display: block;
}
.app.app-compact .nav-backdrop.open {
opacity: 1;
pointer-events: auto;
}
.nav-brand {
display: flex;
@@ -332,10 +367,18 @@ a {
background: var(--bg);
border-bottom: 1px solid var(--border);
}
.topbar-menu {
display: none;
}
.app.app-compact .topbar-menu {
display: inline-flex;
flex-shrink: 0;
}
.topbar .crumbs {
font-size: 12.5px;
color: var(--text-3);
letter-spacing: 0.02em;
min-width: 0;
}
.topbar .crumbs b {
color: var(--text-2);
@@ -349,41 +392,67 @@ a {
padding: 26px 36px 40px;
width: 100%;
}
.page-head {
margin-bottom: 22px;
}
.page-head-row {
display: flex;
align-items: center;
.app.app-compact .topbar {
padding: 0 20px;
gap: 12px;
}
.app.app-compact .content {
padding: 22px 20px 32px;
}
.app.app-compact .cmdk {
width: min(300px, 46vw);
max-width: none;
}
.page-toolbar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.page-toolbar-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
}
.page-head {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 26px;
}
.page-head-icon {
width: 36px;
height: 36px;
width: 50px;
height: 50px;
display: grid;
place-items: center;
border-radius: 10px;
background: var(--accent-soft);
color: var(--accent-h);
border-radius: 15px;
flex-shrink: 0;
color: #05161b;
background: linear-gradient(140deg, var(--accent-h) 0%, var(--accent) 42%, var(--purple) 100%);
box-shadow:
0 14px 30px -12px var(--accent-glow),
0 0 0 1px rgba(255, 255, 255, 0.05),
inset 0 1px 0 rgba(255, 255, 255, 0.5);
}
.page-head-icon svg {
width: 20px;
height: 20px;
width: 24px;
height: 24px;
filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.18));
}
.page-head h1 {
margin: 0;
font-size: 28px;
font-size: 30px;
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;
letter-spacing: -0.025em;
line-height: 1.1;
background: linear-gradient(180deg, var(--text) 30%, var(--text-2));
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
/* ── Cards / panels ───────────────────────────────────────────────────────── */
@@ -413,6 +482,59 @@ a {
padding: 18px;
}
.task-stack {
display: grid;
gap: 14px;
}
.task-panel {
overflow: hidden;
}
.task-toggle {
width: 100%;
display: flex;
align-items: center;
gap: 14px;
padding: 16px 18px;
border: 0;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.task-toggle:hover {
background: rgba(255, 255, 255, 0.02);
}
.task-meta {
min-width: 0;
}
.task-title-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 4px;
}
.task-title-row h3 {
margin: 0;
font-size: 17px;
font-weight: 600;
letter-spacing: -0.01em;
}
.task-summary {
color: var(--text-3);
font-size: 13px;
line-height: 1.45;
}
.task-body {
border-top: 1px solid var(--border);
}
.task-toolbar {
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
justify-content: flex-end;
flex-wrap: wrap;
}
/* ── Section labels (dashboard groupings) ─────────────────────────────────── */
.section-label {
display: flex;
@@ -543,6 +665,205 @@ a {
font-variant-numeric: tabular-nums;
}
/* user activity cards */
.activity-shell {
display: grid;
gap: 18px;
}
.activity-summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 12px;
}
.activity-summary-card {
display: flex;
flex-direction: column;
gap: 6px;
min-height: 108px;
padding: 16px;
border-radius: 14px;
border: 1px solid var(--border);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.02), rgba(255, 255, 255, 0)),
var(--surface2);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.02);
}
.activity-summary-card strong {
font-size: 28px;
line-height: 1;
letter-spacing: -0.03em;
color: var(--text);
font-variant-numeric: tabular-nums;
}
.activity-summary-label {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-3);
}
.activity-summary-sub {
font-size: 12px;
color: var(--text-2);
}
.activity-summary-platform {
position: relative;
padding-left: 54px;
}
.activity-summary-icon {
position: absolute;
left: 16px;
top: 16px;
width: 28px;
height: 28px;
display: grid;
place-items: center;
border-radius: 9px;
background: var(--surface3);
color: var(--text-2);
}
.activity-summary-icon.apple {
background: rgba(54, 214, 224, 0.12);
color: var(--accent-h);
}
.activity-summary-icon.android {
background: rgba(70, 217, 154, 0.12);
color: #8ef0be;
}
.activity-summary-icon.web {
background: rgba(243, 201, 105, 0.12);
color: #f6d98c;
}
.activity-summary-icon svg {
width: 15px;
height: 15px;
}
.activity-card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 14px;
}
.activity-card {
display: flex;
flex-direction: column;
gap: 16px;
padding: 18px;
border-radius: 16px;
border: 1px solid var(--border);
background:
radial-gradient(circle at top right, rgba(54, 214, 224, 0.09), transparent 34%),
linear-gradient(180deg, rgba(255, 255, 255, 0.02), rgba(255, 255, 255, 0)),
var(--surface2);
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.16);
transition: transform 180ms var(--ease-out), border-color 180ms var(--ease-out), box-shadow 180ms var(--ease-out);
}
.activity-card:hover {
transform: translateY(-2px);
border-color: var(--border-strong);
box-shadow: 0 22px 48px rgba(0, 0, 0, 0.22);
}
.activity-card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.activity-user-meta {
min-width: 0;
}
.activity-user-name {
font-size: 16px;
font-weight: 600;
color: var(--text);
letter-spacing: -0.015em;
}
.activity-user-when {
margin-top: 3px;
font-size: 12px;
color: var(--text-3);
}
.activity-device-chip {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 6px 10px;
border-radius: 999px;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.03);
color: var(--text-2);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
white-space: nowrap;
}
.activity-device-chip.apple {
border-color: rgba(54, 214, 224, 0.24);
background: rgba(54, 214, 224, 0.12);
color: var(--accent-h);
}
.activity-device-chip.android {
border-color: rgba(70, 217, 154, 0.24);
background: rgba(70, 217, 154, 0.12);
color: #8ef0be;
}
.activity-device-chip.web {
border-color: rgba(243, 201, 105, 0.24);
background: rgba(243, 201, 105, 0.12);
color: #f6d98c;
}
.activity-device-chip svg {
width: 14px;
height: 14px;
}
.activity-device-title {
color: var(--text);
font-size: 14px;
font-weight: 600;
line-height: 1.45;
}
.activity-card-meta {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.activity-meta-block {
display: flex;
flex-direction: column;
gap: 5px;
padding: 12px 13px;
border-radius: 12px;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.025);
}
.activity-meta-label {
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-3);
}
.activity-meta-value {
font-size: 13px;
color: var(--text-2);
word-break: break-word;
}
@media (max-width: 640px) {
.activity-card {
padding: 16px;
}
.activity-card-head {
flex-direction: column;
align-items: stretch;
}
.activity-device-chip {
align-self: flex-start;
}
.activity-card-meta {
grid-template-columns: 1fr;
}
}
/* ── Completeness rows ────────────────────────────────────────────────────── */
.completeness-row {
display: flex;
@@ -574,6 +895,7 @@ a {
align-items: center;
gap: 12px;
padding: 14px 16px;
min-width: 0;
}
.tool-card:hover {
border-color: var(--border-strong);
@@ -1112,6 +1434,121 @@ input[type="color"] {
padding-left: 36px;
}
/* ── Command palette (topbar) ─────────────────────────────────────────────── */
.cmdk {
position: relative;
width: 340px;
max-width: 42vw;
flex-shrink: 0;
}
.cmdk-field {
position: relative;
display: flex;
align-items: center;
}
.cmdk-field > svg {
position: absolute;
left: 11px;
width: 16px;
height: 16px;
color: var(--text-3);
pointer-events: none;
}
.cmdk-input {
width: 100%;
padding: 8px 44px 8px 34px;
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);
}
.cmdk-input::placeholder {
color: var(--text-3);
}
.cmdk-input:focus {
border-color: var(--border-active);
box-shadow: 0 0 0 3px var(--accent-glow);
outline: none;
}
.cmdk-kbd {
position: absolute;
right: 9px;
font-size: 10.5px;
font-weight: 600;
color: var(--text-3);
background: var(--surface3);
border: 1px solid var(--border);
border-radius: 6px;
padding: 1px 6px;
pointer-events: none;
}
.cmdk-menu {
position: absolute;
top: calc(100% + 8px);
right: 0;
width: min(420px, 80vw);
max-height: 60vh;
overflow-y: auto;
padding: 6px;
border-radius: 12px;
background: var(--surface);
border: 1px solid var(--border);
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.45);
z-index: 60;
}
.cmdk-item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 9px 11px;
border: 0;
border-radius: 9px;
background: none;
color: var(--text-2);
font: inherit;
font-size: 13.5px;
text-align: left;
cursor: pointer;
}
.cmdk-item.active {
background: var(--accent-soft);
color: var(--accent-h);
}
.cmdk-icon {
display: grid;
place-items: center;
flex-shrink: 0;
}
.cmdk-icon svg {
width: 16px;
height: 16px;
opacity: 0.9;
}
.cmdk-label {
flex: 1;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cmdk-section {
font-size: 11px;
color: var(--text-3);
letter-spacing: 0.03em;
text-transform: uppercase;
flex-shrink: 0;
}
.cmdk-empty {
padding: 16px 12px;
text-align: center;
color: var(--text-3);
font-size: 13px;
}
/* ── Two-column workbench (generator) ─────────────────────────────────────── */
.workbench {
display: grid;
@@ -1167,6 +1604,145 @@ input[type="color"] {
max-height: 360px;
overflow-y: auto;
}
.settings-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 16px;
}
.settings-update-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 14px;
}
@media (max-width: 1180px) {
.topbar {
padding: 0 20px;
gap: 12px;
}
.content {
padding: 22px 20px 32px;
}
.cmdk {
width: min(300px, 46vw);
max-width: none;
}
}
@media (max-width: 720px) {
.topbar {
flex-wrap: wrap;
align-items: center;
padding: 10px 16px;
min-height: auto;
}
.topbar .crumbs {
order: 1;
flex: 1 1 0;
font-size: 12px;
line-height: 1.5;
}
.topbar-spacer {
display: none;
}
.cmdk {
order: 3;
width: 100%;
max-width: none;
}
.cmdk-kbd {
display: none;
}
.content {
padding: 18px 16px 28px;
}
.page-toolbar {
flex-direction: column;
align-items: stretch;
}
.page-toolbar-actions {
justify-content: stretch;
}
.page-toolbar-actions > * {
width: 100%;
}
.page-head {
gap: 12px;
margin-bottom: 18px;
}
.page-head-icon {
width: 42px;
height: 42px;
border-radius: 12px;
}
.page-head h1 {
font-size: 24px;
background: none;
-webkit-text-fill-color: initial;
color: var(--text);
}
.mini-grid,
.legend,
.settings-grid,
.settings-update-grid,
.activity-card-grid,
.activity-summary-grid,
.stat-row {
grid-template-columns: 1fr;
}
.genre-row {
grid-template-columns: 1fr;
gap: 8px;
}
.genre-count {
text-align: left;
}
.tool-card {
padding: 13px 14px;
}
.toast-wrap {
left: 16px;
right: 16px;
bottom: 16px;
max-width: none;
}
}
@media (max-width: 520px) {
.nav-brand {
min-height: 58px;
padding: 0 14px;
}
.nav-scroll {
padding: 10px 8px;
}
.nav-foot {
padding: 10px;
}
.panel-head,
.panel-body {
padding-left: 14px;
padding-right: 14px;
}
.panel-head {
flex-wrap: wrap;
align-items: flex-start;
}
.panel-head h3 {
font-size: 16px;
}
.btn,
.chip,
.status-chip {
max-width: 100%;
}
.row.between {
flex-wrap: wrap;
}
.cmdk-menu {
width: 100%;
}
}
.log-line {
display: flex;
gap: 10px;
+1 -1
View File
@@ -1 +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"}
{"root":["./src/app.tsx","./src/api.ts","./src/main.tsx","./src/components/commandpalette.tsx","./src/components/sidebar.tsx","./src/components/icons.tsx","./src/components/ui.tsx","./src/lib/commands.tsx","./src/lib/toast.tsx","./src/pages/dashboard.tsx","./src/pages/settings.tsx","./src/pages/tasks.tsx","./src/pages/audiobookshelf/overview.tsx","./src/pages/emby/airing.tsx","./src/pages/emby/avatargenerator.tsx","./src/pages/emby/bulkassign.tsx","./src/pages/emby/collections.tsx","./src/pages/emby/favorites.tsx","./src/pages/emby/generator.tsx","./src/pages/emby/homescreeneditor.tsx","./src/pages/navidrome/collectioncompleteness.tsx","./src/pages/navidrome/covermanager.tsx","./src/pages/navidrome/library.tsx","./src/pages/navidrome/metadata.tsx","./src/pages/navidrome/reporting.tsx"],"version":"5.9.3"}