Homelabtoolkit v2
This commit is contained in:
+65
-9
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
];
|
||||
@@ -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
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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)" }}>
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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 @@
|
||||
{"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"}
|
||||
Reference in New Issue
Block a user