Homelabtoolkit v1
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>HomelabToolkit</title>
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%2336d6e0'/%3E%3Cpath d='M9 22V10h3v4.5h4V10h3v12h-3v-4.5h-4V22z' fill='%2304181b'/%3E%3C/svg%3E" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1774
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "homelab-toolkit-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"typescript": "^5.6.2",
|
||||
"vite": "^5.4.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Navigate, Route, Routes, useLocation } from "react-router-dom";
|
||||
import Sidebar from "./components/Sidebar";
|
||||
import { AppConfig, apiGet } from "./api";
|
||||
import Dashboard from "./pages/Dashboard";
|
||||
import Generator from "./pages/emby/Generator";
|
||||
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 Library from "./pages/navidrome/Library";
|
||||
import CoverManager from "./pages/navidrome/CoverManager";
|
||||
import CollectionCompleteness from "./pages/navidrome/CollectionCompleteness";
|
||||
import Settings from "./pages/Settings";
|
||||
|
||||
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"],
|
||||
"/navidrome/library": ["Navidrome", "Music Library"],
|
||||
"/navidrome/covers": ["Navidrome", "Cover Manager"],
|
||||
"/collection-completeness": ["Navidrome", "Collection Completeness"],
|
||||
"/settings": ["System", "Settings"],
|
||||
};
|
||||
|
||||
export default function App() {
|
||||
const [config, setConfig] = useState<AppConfig | null>(null);
|
||||
const [navidromeConnected, setNavidromeConnected] = useState(false);
|
||||
const location = useLocation();
|
||||
|
||||
function refreshConfig() {
|
||||
apiGet<AppConfig>("/api/config")
|
||||
.then(setConfig)
|
||||
.catch(() => setConfig(null));
|
||||
apiGet<{ connected: boolean }>("/api/navidrome/status")
|
||||
.then((s) => setNavidromeConnected(!!s.connected))
|
||||
.catch(() => setNavidromeConnected(false));
|
||||
}
|
||||
|
||||
useEffect(refreshConfig, []);
|
||||
|
||||
const [section, page] = CRUMBS[location.pathname] || ["", ""];
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<Sidebar config={config} navidromeConnected={navidromeConnected} />
|
||||
<div className="main">
|
||||
<header className="topbar">
|
||||
<div className="crumbs">
|
||||
{section && (
|
||||
<>
|
||||
<b>{section}</b> / {" "}
|
||||
</>
|
||||
)}
|
||||
{page}
|
||||
</div>
|
||||
<div className="topbar-spacer" />
|
||||
</header>
|
||||
<div className="content">
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard config={config} navidromeConnected={navidromeConnected} />} />
|
||||
<Route path="/emby/generator" element={<Generator />} />
|
||||
<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="/navidrome/library" element={<Library />} />
|
||||
<Route path="/navidrome/covers" element={<CoverManager />} />
|
||||
<Route path="/collection-completeness" element={<CollectionCompleteness />} />
|
||||
<Route path="/settings" element={<Settings onSaved={refreshConfig} />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// Thin fetch wrapper around the FastAPI backend. All endpoints are same-origin
|
||||
// (the SPA is served by FastAPI in production; proxied in dev via vite.config).
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
constructor(message: string, status: number) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
async function parseError(res: Response): Promise<string> {
|
||||
try {
|
||||
const data = await res.json();
|
||||
if (typeof data?.detail === "string") return data.detail;
|
||||
if (Array.isArray(data?.detail)) return data.detail.map((d: any) => d.msg).join(", ");
|
||||
return res.statusText;
|
||||
} catch {
|
||||
return res.statusText || `Request failed (${res.status})`;
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiGet<T = any>(path: string): Promise<T> {
|
||||
const res = await fetch(path);
|
||||
if (!res.ok) throw new ApiError(await parseError(res), res.status);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function apiPost<T = any>(path: string, body?: unknown): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) throw new ApiError(await parseError(res), res.status);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Returns the rendered image as an object URL plus the X-Cache-Key header.
|
||||
export async function apiPostImage(
|
||||
path: string,
|
||||
body: unknown
|
||||
): Promise<{ url: string; cacheKey: string | null }> {
|
||||
const res = await fetch(path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) throw new ApiError(await parseError(res), res.status);
|
||||
const blob = await res.blob();
|
||||
return { url: URL.createObjectURL(blob), cacheKey: res.headers.get("X-Cache-Key") };
|
||||
}
|
||||
|
||||
export async function uploadBackground(file: File): Promise<{ upload_id: string; width: number; height: number }> {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
const res = await fetch("/api/upload-background", { 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 };
|
||||
music: { root: string; available: boolean };
|
||||
}
|
||||
|
||||
export interface SearchItem {
|
||||
id: string;
|
||||
name: string;
|
||||
year: number | string;
|
||||
type: string;
|
||||
has_logo: boolean;
|
||||
auto_studio: string | null;
|
||||
backdrop_count: number;
|
||||
poster_url: string;
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { NavLink, useLocation } from "react-router-dom";
|
||||
import { AppConfig } from "../api";
|
||||
import {
|
||||
IconCalendar,
|
||||
IconChevron,
|
||||
IconDisc,
|
||||
IconEmby,
|
||||
IconGrid,
|
||||
IconHeart,
|
||||
IconHome,
|
||||
IconImage,
|
||||
IconLayers,
|
||||
IconMusic,
|
||||
IconSettings,
|
||||
IconWand,
|
||||
} from "./icons";
|
||||
|
||||
interface NavItem {
|
||||
to: string;
|
||||
label: string;
|
||||
icon: JSX.Element;
|
||||
}
|
||||
|
||||
const GROUPS: { id: string; label: string; icon: JSX.Element; links: NavItem[] }[] = [
|
||||
{
|
||||
id: "emby",
|
||||
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 /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "navidrome",
|
||||
label: "Navidrome",
|
||||
icon: <IconMusic />,
|
||||
links: [
|
||||
{ to: "/navidrome/library", label: "Music Library", icon: <IconDisc /> },
|
||||
{ to: "/navidrome/covers", label: "Cover Manager", icon: <IconWand /> },
|
||||
{ to: "/collection-completeness", label: "Collection Completeness", icon: <IconLayers /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "system",
|
||||
label: "System",
|
||||
icon: <IconSettings />,
|
||||
links: [{ to: "/settings", label: "Settings", icon: <IconSettings /> }],
|
||||
},
|
||||
];
|
||||
|
||||
function Chip({ label, ok, configured }: { label: string; ok: boolean; configured: boolean }) {
|
||||
const cls = !configured ? "idle" : ok ? "" : "off";
|
||||
const text = !configured ? "Not configured" : ok ? "Connected" : "Offline";
|
||||
return (
|
||||
<div className="status-chip">
|
||||
<span className={`dot ${cls}`} />
|
||||
<span className="label">{label}</span>
|
||||
<span className="dim" style={{ fontSize: 11 }}>
|
||||
{text}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
config: AppConfig | null;
|
||||
navidromeConnected: boolean;
|
||||
}
|
||||
|
||||
export default function Sidebar({ config, navidromeConnected }: 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 } : {}));
|
||||
|
||||
useEffect(() => {
|
||||
if (activeGroup) setOpen((o) => (o[activeGroup] ? o : { ...o, [activeGroup]: true }));
|
||||
}, [activeGroup]);
|
||||
|
||||
const toggle = (id: string) => setOpen((o) => ({ ...o, [id]: !o[id] }));
|
||||
|
||||
return (
|
||||
<nav className="nav">
|
||||
<div className="nav-brand">
|
||||
<div className="nav-logo">H</div>
|
||||
<div className="nav-name">
|
||||
HomelabToolkit
|
||||
<small>media operations</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="nav-scroll">
|
||||
<div className="nav-group">
|
||||
<NavLink to="/" end className={({ isActive }) => `nav-item nav-item-top ${isActive ? "active" : ""}`}>
|
||||
<IconHome />
|
||||
Dashboard
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
{GROUPS.map((g) => {
|
||||
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}>
|
||||
{g.icon}
|
||||
<span className="grow" style={{ textAlign: "left" }}>
|
||||
{g.label}
|
||||
</span>
|
||||
<IconChevron className={`nav-caret ${isOpen ? "open" : ""}`} />
|
||||
</button>
|
||||
<div className={`nav-group-panel ${isOpen ? "open" : ""}`}>
|
||||
<div className="nav-group-inner">
|
||||
{g.links.map((l) => (
|
||||
<NavLink
|
||||
key={l.to}
|
||||
to={l.to}
|
||||
className={({ isActive }) => `nav-item ${isActive ? "active" : ""}`}
|
||||
>
|
||||
{l.icon}
|
||||
{l.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="nav-foot">
|
||||
<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>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
// Minimal feather-style icon set (stroke=currentColor) used across the app.
|
||||
// A default 18px size keeps every icon consistent even where no CSS rule scopes
|
||||
// it (e.g. category headers); callers can still override via CSS or the size prop.
|
||||
type P = { className?: string };
|
||||
const base = {
|
||||
fill: "none",
|
||||
stroke: "currentColor",
|
||||
strokeWidth: 2,
|
||||
strokeLinecap: "round" as const,
|
||||
strokeLinejoin: "round" as const,
|
||||
viewBox: "0 0 24 24",
|
||||
width: 18,
|
||||
height: 18,
|
||||
};
|
||||
|
||||
export const IconImage = (p: P) => (
|
||||
<svg {...base} {...p}>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||
<path d="M21 15l-5-5L5 21" />
|
||||
</svg>
|
||||
);
|
||||
export const IconLayers = (p: P) => (
|
||||
<svg {...base} {...p}>
|
||||
<path d="M12 2l9 5-9 5-9-5 9-5z" />
|
||||
<path d="M3 12l9 5 9-5M3 17l9 5 9-5" />
|
||||
</svg>
|
||||
);
|
||||
export const IconCalendar = (p: P) => (
|
||||
<svg {...base} {...p}>
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" />
|
||||
<path d="M16 2v4M8 2v4M3 10h18" />
|
||||
</svg>
|
||||
);
|
||||
export const IconGrid = (p: P) => (
|
||||
<svg {...base} {...p}>
|
||||
<rect x="3" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="14" width="7" height="7" rx="1" />
|
||||
<rect x="3" y="14" width="7" height="7" rx="1" />
|
||||
</svg>
|
||||
);
|
||||
export const IconHeart = (p: P) => (
|
||||
<svg {...base} {...p}>
|
||||
<path d="M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.7l-1-1.1a5.5 5.5 0 0 0-7.8 7.8l1 1L12 21l7.8-7.6 1-1a5.5 5.5 0 0 0 0-7.8z" />
|
||||
</svg>
|
||||
);
|
||||
export const IconMusic = (p: P) => (
|
||||
<svg {...base} {...p}>
|
||||
<path d="M9 18V5l12-2v13" />
|
||||
<circle cx="6" cy="18" r="3" />
|
||||
<circle cx="18" cy="16" r="3" />
|
||||
</svg>
|
||||
);
|
||||
export const IconDisc = (p: P) => (
|
||||
<svg {...base} {...p}>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
);
|
||||
export const IconWand = (p: P) => (
|
||||
<svg {...base} {...p}>
|
||||
<path d="M15 4V2M15 16v-2M8 9h2M20 9h2M17.8 11.8L19 13M15 9l-7 7-4 4" />
|
||||
<path d="M17.8 6.2L19 5" />
|
||||
</svg>
|
||||
);
|
||||
export const IconSearch = (p: P) => (
|
||||
<svg {...base} {...p}>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="M21 21l-4.3-4.3" />
|
||||
</svg>
|
||||
);
|
||||
export const IconHome = (p: P) => (
|
||||
<svg {...base} {...p}>
|
||||
<path d="M3 9.5L12 3l9 6.5V20a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1z" />
|
||||
</svg>
|
||||
);
|
||||
export const IconServer = (p: P) => (
|
||||
<svg {...base} {...p}>
|
||||
<rect x="3" y="3" width="18" height="7" rx="2" />
|
||||
<rect x="3" y="14" width="18" height="7" rx="2" />
|
||||
<path d="M7 6.5h.01M7 17.5h.01" />
|
||||
</svg>
|
||||
);
|
||||
export const IconRefresh = (p: P) => (
|
||||
<svg {...base} {...p}>
|
||||
<path d="M21 12a9 9 0 1 1-3-6.7L21 8" />
|
||||
<path d="M21 3v5h-5" />
|
||||
</svg>
|
||||
);
|
||||
export const IconCheck = (p: P) => (
|
||||
<svg {...base} {...p}>
|
||||
<path d="M20 6L9 17l-5-5" />
|
||||
</svg>
|
||||
);
|
||||
export const IconUpload = (p: P) => (
|
||||
<svg {...base} {...p}>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<path d="M17 8l-5-5-5 5M12 3v12" />
|
||||
</svg>
|
||||
);
|
||||
export const IconTrash = (p: P) => (
|
||||
<svg {...base} {...p}>
|
||||
<path d="M3 6h18M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||
</svg>
|
||||
);
|
||||
export const IconPlay = (p: P) => (
|
||||
<svg {...base} {...p}>
|
||||
<path d="M6 4l14 8-14 8V4z" />
|
||||
</svg>
|
||||
);
|
||||
export const IconUser = (p: P) => (
|
||||
<svg {...base} {...p}>
|
||||
<circle cx="12" cy="8" r="4" />
|
||||
<path d="M4 21v-1a6 6 0 0 1 6-6h4a6 6 0 0 1 6 6v1" />
|
||||
</svg>
|
||||
);
|
||||
export const IconChevron = (p: P) => (
|
||||
<svg {...base} {...p}>
|
||||
<path d="M9 18l6-6-6-6" />
|
||||
</svg>
|
||||
);
|
||||
export const IconFolder = (p: P) => (
|
||||
<svg {...base} {...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 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>
|
||||
);
|
||||
// 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) => (
|
||||
<svg viewBox="0 0 24 24" fill="none" width={18} height={18} className={p.className}>
|
||||
<rect x="3" y="3" width="18" height="18" rx="5.5" stroke="currentColor" strokeWidth={2} />
|
||||
<path d="M10 8.3l5.4 3.7-5.4 3.7z" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
@@ -0,0 +1,119 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export function PageHead({ title, icon, children }: { title: string; icon?: ReactNode; children?: 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>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Avatar({ name }: { name: string }) {
|
||||
const initials =
|
||||
name
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((w) => w[0])
|
||||
.join("")
|
||||
.toUpperCase() || "?";
|
||||
// Deterministic hue from the name so each user keeps a stable color.
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) hash = (hash * 31 + name.charCodeAt(i)) % 360;
|
||||
return (
|
||||
<span
|
||||
className="avatar"
|
||||
style={{ background: `linear-gradient(155deg, hsl(${hash} 70% 58%), hsl(${(hash + 40) % 360} 65% 45%))` }}
|
||||
>
|
||||
{initials}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function formatNZ(iso: string | null | undefined): string {
|
||||
if (!iso) return "Never";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "Never";
|
||||
return d.toLocaleString("en-NZ", {
|
||||
timeZone: "Pacific/Auckland",
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function StatCard({ icon, value, label }: { icon: ReactNode; value: ReactNode; label: string }) {
|
||||
return (
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon">{icon}</div>
|
||||
<div className="stat-meta">
|
||||
<div className="stat-value">{value}</div>
|
||||
<div className="stat-label">{label}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Empty({ icon, children }: { icon?: ReactNode; children: ReactNode }) {
|
||||
return (
|
||||
<div className="empty">
|
||||
{icon}
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Loading({ label }: { label?: string }) {
|
||||
return (
|
||||
<div className="center-load col" style={{ alignItems: "center", gap: 12 }}>
|
||||
<span className="spinner lg" />
|
||||
{label && <span className="dim">{label}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function timeAgo(iso: string | null | undefined): string {
|
||||
if (!iso) return "—";
|
||||
const then = new Date(iso).getTime();
|
||||
if (Number.isNaN(then)) return "—";
|
||||
const secs = Math.max(0, (Date.now() - then) / 1000);
|
||||
const units: [number, string][] = [
|
||||
[60, "second"],
|
||||
[60, "minute"],
|
||||
[24, "hour"],
|
||||
[30, "day"],
|
||||
[12, "month"],
|
||||
[Number.POSITIVE_INFINITY, "year"],
|
||||
];
|
||||
let value = secs;
|
||||
for (const [size, name] of units) {
|
||||
if (value < size) {
|
||||
const v = Math.floor(value);
|
||||
return `${v} ${name}${v === 1 ? "" : "s"} ago`;
|
||||
}
|
||||
value /= size;
|
||||
}
|
||||
return "—";
|
||||
}
|
||||
|
||||
export function fmtNumber(n: number | null | undefined): string {
|
||||
if (n == null) return "—";
|
||||
return n.toLocaleString();
|
||||
}
|
||||
|
||||
export function fmtDuration(seconds: number): string {
|
||||
if (!seconds) return "0:00";
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
if (m >= 60) {
|
||||
const h = Math.floor(m / 60);
|
||||
return `${h}h ${m % 60}m`;
|
||||
}
|
||||
return `${m}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { createContext, useCallback, useContext, useState, ReactNode } from "react";
|
||||
|
||||
type ToastKind = "ok" | "err" | "info";
|
||||
interface Toast {
|
||||
id: number;
|
||||
message: string;
|
||||
kind: ToastKind;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<(message: string, kind?: ToastKind) => void>(() => {});
|
||||
|
||||
export function useToast() {
|
||||
return useContext(ToastContext);
|
||||
}
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
const push = useCallback((message: string, kind: ToastKind = "info") => {
|
||||
const id = Date.now() + Math.random();
|
||||
setToasts((t) => [...t, { id, message, kind }]);
|
||||
setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 4200);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={push}>
|
||||
{children}
|
||||
<div className="toast-wrap">
|
||||
{toasts.map((t) => (
|
||||
<div key={t.id} className={`toast ${t.kind === "info" ? "" : t.kind}`}>
|
||||
{t.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App";
|
||||
import { ToastProvider } from "./lib/toast";
|
||||
import "./styles.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<ToastProvider>
|
||||
<App />
|
||||
</ToastProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,411 @@
|
||||
import { useEffect, useState, ReactNode } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { AppConfig, apiGet, apiPost } from "../api";
|
||||
import { useToast } from "../lib/toast";
|
||||
import { PageHead, StatCard, Loading, Empty, Avatar, timeAgo, fmtNumber, formatNZ } from "../components/ui";
|
||||
import {
|
||||
IconCalendar,
|
||||
IconChevron,
|
||||
IconDisc,
|
||||
IconEmby,
|
||||
IconGrid,
|
||||
IconHeart,
|
||||
IconHome,
|
||||
IconImage,
|
||||
IconLayers,
|
||||
IconMusic,
|
||||
IconPlay,
|
||||
IconRefresh,
|
||||
IconUser,
|
||||
IconWand,
|
||||
} from "../components/icons";
|
||||
|
||||
interface Props {
|
||||
config: AppConfig | null;
|
||||
navidromeConnected: boolean;
|
||||
}
|
||||
|
||||
interface DashboardData {
|
||||
emby: {
|
||||
connected: boolean;
|
||||
url: string;
|
||||
movies: number;
|
||||
series: number;
|
||||
episodes: number;
|
||||
collections: number;
|
||||
users: number;
|
||||
favorites_collections: number;
|
||||
last_added: string | null;
|
||||
};
|
||||
navidrome: {
|
||||
connected: boolean;
|
||||
configured?: boolean;
|
||||
artist_count?: number;
|
||||
album_count?: number;
|
||||
song_count?: number;
|
||||
genre_count?: number;
|
||||
top_genres?: { name: string; song_count: number; album_count: number }[];
|
||||
};
|
||||
music: { available: boolean; root: string };
|
||||
}
|
||||
|
||||
interface UserActivity {
|
||||
id: string;
|
||||
name: string;
|
||||
last_login: string | null;
|
||||
last_activity: string | null;
|
||||
ip: string | null;
|
||||
device: string | null;
|
||||
client: string | null;
|
||||
}
|
||||
|
||||
interface ActivitySummary {
|
||||
user_count: number;
|
||||
device_count: number;
|
||||
platforms: { android: number; ios: number; web: number; other: number };
|
||||
platform_pct: { android: number; ios: number; web: number; other: number };
|
||||
}
|
||||
|
||||
interface FormatData {
|
||||
total: number;
|
||||
formats: { format: string; count: number }[];
|
||||
}
|
||||
|
||||
const FORMAT_COLORS: Record<string, string> = {
|
||||
flac: "var(--accent)",
|
||||
mp3: "var(--amber)",
|
||||
m4a: "var(--green)",
|
||||
aac: "var(--green)",
|
||||
alac: "#7fd1ff",
|
||||
ogg: "var(--purple)",
|
||||
opus: "var(--purple)",
|
||||
wav: "#8fa3b8",
|
||||
wma: "#c98cf3",
|
||||
other: "var(--text-3)",
|
||||
};
|
||||
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: "/navidrome/library", label: "Music Library", icon: <IconDisc />, cat: "Navidrome" },
|
||||
{ to: "/navidrome/covers", label: "Cover Manager", icon: <IconWand />, cat: "Navidrome" },
|
||||
];
|
||||
|
||||
function MiniStat({ icon, value, label }: { icon: ReactNode; value: ReactNode; label: string }) {
|
||||
return (
|
||||
<div className="mini-stat">
|
||||
<div className="stat-icon">{icon}</div>
|
||||
<div className="stat-meta">
|
||||
<div className="stat-value">{value}</div>
|
||||
<div className="stat-label">{label}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ configured, connected }: { configured: boolean; connected: boolean }) {
|
||||
if (!configured) return <span className="badge">not configured</span>;
|
||||
return <span className={`badge ${connected ? "badge-ok" : "badge-bad"}`}>{connected ? "connected" : "offline"}</span>;
|
||||
}
|
||||
|
||||
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 [formatsLoading, setFormatsLoading] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [embyScanning, setEmbyScanning] = useState(false);
|
||||
const [navScanning, setNavScanning] = useState(false);
|
||||
|
||||
async function refreshEmby() {
|
||||
setEmbyScanning(true);
|
||||
try {
|
||||
await apiPost("/api/emby/refresh-libraries");
|
||||
toast("Emby library scan started", "ok");
|
||||
} catch (err: any) {
|
||||
toast(err.message, "err");
|
||||
} finally {
|
||||
setEmbyScanning(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function scanNavidrome() {
|
||||
setNavScanning(true);
|
||||
try {
|
||||
await apiPost("/api/navidrome/scan");
|
||||
toast("Navidrome scan started", "ok");
|
||||
} catch (err: any) {
|
||||
toast(err.message, "err");
|
||||
} finally {
|
||||
setNavScanning(false);
|
||||
}
|
||||
}
|
||||
|
||||
function load() {
|
||||
setLoading(true);
|
||||
apiGet<DashboardData>("/api/dashboard")
|
||||
.then(setData)
|
||||
.catch(() => setData(null))
|
||||
.finally(() => setLoading(false));
|
||||
apiGet<{ users: UserActivity[]; summary: ActivitySummary }>("/api/emby/user-activity")
|
||||
.then((d) => {
|
||||
setActivity(d.users);
|
||||
setActivitySummary(d.summary);
|
||||
})
|
||||
.catch(() => {
|
||||
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));
|
||||
}
|
||||
useEffect(load, []);
|
||||
|
||||
const e = data?.emby;
|
||||
const n = data?.navidrome;
|
||||
const genres = n?.top_genres ?? [];
|
||||
const maxGenre = genres.reduce((m, g) => Math.max(m, g.song_count), 0) || 1;
|
||||
|
||||
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}>
|
||||
{loading ? <span className="spinner" /> : <IconRefresh />} Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && !data ? (
|
||||
<Loading label="Gathering library stats…" />
|
||||
) : (
|
||||
<>
|
||||
<div className="dash-split">
|
||||
{/* ── Emby column ── */}
|
||||
<div className="panel">
|
||||
<div className="panel-head">
|
||||
<div className="stat-icon" style={{ width: 30, height: 30, borderRadius: 8 }}>
|
||||
<IconEmby />
|
||||
</div>
|
||||
<h3 className="grow">Emby</h3>
|
||||
<StatusBadge configured={!!e?.connected} connected={!!e?.connected} />
|
||||
<button className="btn btn-sm" onClick={refreshEmby} disabled={embyScanning || !e?.connected}>
|
||||
{embyScanning ? <span className="spinner" /> : <IconRefresh />} Scan libraries
|
||||
</button>
|
||||
</div>
|
||||
<div className="panel-body col" style={{ gap: 14 }}>
|
||||
<div className="mini-grid">
|
||||
<MiniStat icon={<IconPlay />} value={fmtNumber(e?.movies)} label="Movies" />
|
||||
<MiniStat icon={<IconGrid />} value={fmtNumber(e?.series)} label="Series" />
|
||||
<MiniStat icon={<IconLayers />} value={fmtNumber(e?.episodes)} label="Episodes" />
|
||||
<MiniStat icon={<IconLayers />} value={fmtNumber(e?.collections)} label="Collections" />
|
||||
<MiniStat icon={<IconUser />} value={fmtNumber(e?.users)} label="Users" />
|
||||
<MiniStat icon={<IconHeart />} value={fmtNumber(e?.favorites_collections)} label="Favorites" />
|
||||
</div>
|
||||
<div className="mini-stat">
|
||||
<div className="stat-icon">
|
||||
<IconCalendar />
|
||||
</div>
|
||||
<div className="stat-meta">
|
||||
<div className="stat-value" style={{ fontSize: 17 }}>
|
||||
{timeAgo(e?.last_added)}
|
||||
</div>
|
||||
<div className="stat-label">Last item added · {e?.url}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Navidrome column ── */}
|
||||
<div className="panel">
|
||||
<div className="panel-head">
|
||||
<div className="stat-icon" style={{ width: 30, height: 30, borderRadius: 8 }}>
|
||||
<IconMusic />
|
||||
</div>
|
||||
<h3 className="grow">Navidrome</h3>
|
||||
<StatusBadge configured={!!n?.configured} connected={!!n?.connected} />
|
||||
<button className="btn btn-sm" onClick={scanNavidrome} disabled={navScanning || !n?.connected}>
|
||||
{navScanning ? <span className="spinner" /> : <IconRefresh />} Scan library
|
||||
</button>
|
||||
</div>
|
||||
<div className="panel-body col" style={{ gap: 14 }}>
|
||||
{!n?.connected ? (
|
||||
<Empty icon={<IconMusic />}>
|
||||
{n?.configured
|
||||
? "Navidrome is configured but offline."
|
||||
: "Navidrome is not configured. Add your server details in Settings."}
|
||||
</Empty>
|
||||
) : (
|
||||
<>
|
||||
<div className="mini-grid">
|
||||
<MiniStat icon={<IconUser />} value={fmtNumber(n?.artist_count)} label="Artists" />
|
||||
<MiniStat icon={<IconDisc />} value={fmtNumber(n?.album_count)} label="Albums" />
|
||||
<MiniStat icon={<IconMusic />} value={fmtNumber(n?.song_count)} label="Tracks" />
|
||||
<MiniStat icon={<IconLayers />} value={fmtNumber(n?.genre_count)} label="Genres" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="section-label" style={{ margin: "4px 0 8px" }}>
|
||||
Audio formats
|
||||
</div>
|
||||
{formatsLoading && !formats ? (
|
||||
<p className="hint row gap-sm">
|
||||
<span className="spinner" /> Analyzing track formats…
|
||||
</p>
|
||||
) : !formats || formats.total === 0 ? (
|
||||
<p className="hint">No format data.</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="stack-bar">
|
||||
{formats.formats.map((f) => (
|
||||
<span
|
||||
key={f.format}
|
||||
className="stack-seg"
|
||||
title={`${f.format}: ${f.count}`}
|
||||
style={{ width: `${(f.count / formats.total) * 100}%`, background: formatColor(f.format) }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="legend">
|
||||
{formats.formats.map((f) => (
|
||||
<div className="legend-row" key={f.format}>
|
||||
<span className="legend-dot" style={{ background: formatColor(f.format) }} />
|
||||
<span className="legend-name">{f.format}</span>
|
||||
<span className="legend-count">
|
||||
{fmtNumber(f.count)} · {Math.round((f.count / formats.total) * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="section-label" style={{ margin: "4px 0 8px" }}>
|
||||
Top genres
|
||||
</div>
|
||||
{genres.length === 0 ? (
|
||||
<p className="hint">No genre data.</p>
|
||||
) : (
|
||||
genres.map((g) => (
|
||||
<div className="genre-row" key={g.name}>
|
||||
<span className="genre-name">{g.name}</span>
|
||||
<span className="genre-bar">
|
||||
<span className="genre-bar-fill" style={{ width: `${(g.song_count / maxGenre) * 100}%` }} />
|
||||
</span>
|
||||
<span className="genre-count">{fmtNumber(g.song_count)}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── User activity (full width) ── */}
|
||||
<div className="panel" style={{ marginBottom: 26 }}>
|
||||
<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">
|
||||
<Loading />
|
||||
</div>
|
||||
) : 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>
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Quick actions ── */}
|
||||
<div className="section-label">Tools</div>
|
||||
<div className="card-grid" style={{ gridTemplateColumns: "repeat(auto-fill, minmax(230px, 1fr))" }}>
|
||||
{tools.map((t) => (
|
||||
<Link key={t.to} to={t.to} className="panel tool-card">
|
||||
<div className="stat-icon" style={{ width: 32, height: 32 }}>
|
||||
{t.icon}
|
||||
</div>
|
||||
<div className="grow">
|
||||
<div className="media-title">{t.label}</div>
|
||||
<div className="dim" style={{ fontSize: 11 }}>
|
||||
{t.cat}
|
||||
</div>
|
||||
</div>
|
||||
<IconChevron className="dim" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
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 { useToast } from "../lib/toast";
|
||||
|
||||
interface SettingsValues {
|
||||
emby_url: string;
|
||||
emby_api_key: string;
|
||||
navidrome_url: string;
|
||||
navidrome_user: string;
|
||||
navidrome_password: string;
|
||||
music_root: 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" },
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
apiGet<SettingsValues>("/api/settings")
|
||||
.then(setValues)
|
||||
.catch((e) => toast(e.message, "err"));
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
function set<K extends keyof SettingsValues>(k: K, v: string) {
|
||||
setValues((s) => (s ? { ...s, [k]: v } : s));
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!values) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = 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");
|
||||
}
|
||||
onSaved?.();
|
||||
} catch (e: any) {
|
||||
toast(e.message, "err");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
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"] },
|
||||
];
|
||||
|
||||
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="col" style={{ maxWidth: 640, gap: 16 }}>
|
||||
{groups.map((g) => (
|
||||
<div className="panel" key={g.title}>
|
||||
<div className="panel-head">
|
||||
<div className="stat-icon" style={{ width: 30, height: 30, borderRadius: 8 }}>
|
||||
{g.icon}
|
||||
</div>
|
||||
<h3>{g.title}</h3>
|
||||
</div>
|
||||
<div className="panel-body col" style={{ gap: 14 }}>
|
||||
{g.keys.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>
|
||||
))}
|
||||
|
||||
<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>
|
||||
<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,177 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { apiGet, apiPost } from "../../api";
|
||||
import { PageHead, Empty, Loading } from "../../components/ui";
|
||||
import { IconCalendar, IconCheck, IconRefresh } from "../../components/icons";
|
||||
import { useToast } from "../../lib/toast";
|
||||
|
||||
interface AiringItem {
|
||||
id: string;
|
||||
name: string;
|
||||
year?: number;
|
||||
status: string;
|
||||
air_days: string[];
|
||||
poster_url: string;
|
||||
has_logo: boolean;
|
||||
selected_week_air_at: string | null;
|
||||
selected_week_episode_label: string | null;
|
||||
next_air_at: string | null;
|
||||
next_episode_label: string | null;
|
||||
season_number: number | null;
|
||||
eligible_new_season: boolean;
|
||||
}
|
||||
interface Snapshot {
|
||||
items: AiringItem[];
|
||||
week_start: string;
|
||||
week_end: string;
|
||||
week_offset: number;
|
||||
}
|
||||
|
||||
const WEEKS = [
|
||||
{ off: -1, label: "Last week" },
|
||||
{ off: 0, label: "This week" },
|
||||
{ off: 1, label: "Next week" },
|
||||
];
|
||||
|
||||
function fmtDate(iso: string | null) {
|
||||
if (!iso) return null;
|
||||
return new Date(iso).toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
export default function Airing() {
|
||||
const toast = useToast();
|
||||
const [snap, setSnap] = useState<Snapshot | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [weekOffset, setWeekOffset] = useState(0);
|
||||
const [eligibleOnly, setEligibleOnly] = useState(false);
|
||||
const [busy, setBusy] = useState<Record<string, boolean>>({});
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
|
||||
const load = useCallback(
|
||||
(refresh = false) => {
|
||||
setLoading(true);
|
||||
apiGet<Snapshot>(`/api/airing?week_offset=${weekOffset}&eligible_only=${eligibleOnly}&limit=48&refresh=${refresh}`)
|
||||
.then(setSnap)
|
||||
.catch((e) => toast(e.message, "err"))
|
||||
.finally(() => setLoading(false));
|
||||
},
|
||||
[weekOffset, eligibleOnly, toast]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
async function applyOne(item: AiringItem) {
|
||||
setBusy((b) => ({ ...b, [item.id]: true }));
|
||||
try {
|
||||
await apiPost("/api/airing/apply-new-season", {
|
||||
item_id: item.id,
|
||||
generate_primary: true,
|
||||
week_offset: weekOffset,
|
||||
});
|
||||
toast(`New Season artwork applied to ${item.name}`, "ok");
|
||||
} catch (e: any) {
|
||||
toast(e.message, "err");
|
||||
} finally {
|
||||
setBusy((b) => ({ ...b, [item.id]: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function applySelected() {
|
||||
const ids = [...selected];
|
||||
if (!ids.length) return;
|
||||
try {
|
||||
const res = await apiPost("/api/airing/apply-new-season/bulk", { item_ids: ids });
|
||||
toast(`Applied to ${res.applied_count} series`, "ok");
|
||||
setSelected(new Set());
|
||||
} catch (e: any) {
|
||||
toast(e.message, "err");
|
||||
}
|
||||
}
|
||||
|
||||
function toggle(id: string) {
|
||||
setSelected((s) => {
|
||||
const n = new Set(s);
|
||||
n.has(id) ? n.delete(id) : n.add(id);
|
||||
return n;
|
||||
});
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<div className="row between wrap" style={{ marginBottom: 18, gap: 12 }}>
|
||||
<div className="row gap-sm wrap">
|
||||
<div className="seg">
|
||||
{WEEKS.map((w) => (
|
||||
<button key={w.off} className={`seg-btn ${weekOffset === w.off ? "active" : ""}`} onClick={() => setWeekOffset(w.off)}>
|
||||
{w.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button className={`chip ${eligibleOnly ? "active" : ""}`} onClick={() => setEligibleOnly((v) => !v)}>
|
||||
Eligible only
|
||||
</button>
|
||||
</div>
|
||||
<div className="row gap-sm">
|
||||
{selected.size > 0 && (
|
||||
<button className="btn btn-primary" onClick={applySelected}>
|
||||
<IconCheck /> Apply {selected.size} selected
|
||||
</button>
|
||||
)}
|
||||
<button className="btn" onClick={() => load(true)}>
|
||||
<IconRefresh /> Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<Loading label="Loading airing snapshot…" />
|
||||
) : !snap?.items.length ? (
|
||||
<div className="panel">
|
||||
<Empty icon={<IconCalendar />}>No airing series found for this week.</Empty>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card-grid" style={{ gridTemplateColumns: "repeat(auto-fill, minmax(230px, 1fr))" }}>
|
||||
{snap.items.map((item) => (
|
||||
<div key={item.id} className={`panel ${selected.has(item.id) ? "selected" : ""}`} style={{ overflow: "hidden" }}>
|
||||
<div style={{ display: "flex", gap: 12, padding: 12 }}>
|
||||
<img src={item.poster_url} className="result-poster" style={{ width: 56, height: 84 }} alt="" loading="lazy" />
|
||||
<div className="grow" style={{ minWidth: 0 }}>
|
||||
<div className="media-title">{item.name}</div>
|
||||
<div className="row wrap" style={{ gap: 5, marginTop: 5 }}>
|
||||
<span className={`badge ${item.status.toLowerCase() === "continuing" ? "badge-ok" : ""}`}>{item.status}</span>
|
||||
{item.eligible_new_season && <span className="badge badge-accent">eligible</span>}
|
||||
{!item.has_logo && <span className="badge badge-warn">no logo</span>}
|
||||
</div>
|
||||
<div className="hint" style={{ marginTop: 6 }}>
|
||||
{item.selected_week_episode_label || item.next_episode_label || "—"}
|
||||
{fmtDate(item.selected_week_air_at || item.next_air_at) && (
|
||||
<div className="dim mono">{fmtDate(item.selected_week_air_at || item.next_air_at)}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="row" style={{ padding: "0 12px 12px", gap: 8 }}>
|
||||
<label className="chip" style={{ padding: "6px 10px" }}>
|
||||
<input type="checkbox" checked={selected.has(item.id)} onChange={() => toggle(item.id)} /> Select
|
||||
</label>
|
||||
<button
|
||||
className="btn btn-sm btn-primary grow"
|
||||
disabled={!item.has_logo || !item.eligible_new_season || busy[item.id]}
|
||||
onClick={() => applyOne(item)}
|
||||
>
|
||||
{busy[item.id] ? <span className="spinner" /> : <IconCheck />} New Season
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { apiGet, apiPost } from "../../api";
|
||||
import { PageHead, Empty, Loading } from "../../components/ui";
|
||||
import { IconCheck, IconGrid, IconRefresh, IconSearch } from "../../components/icons";
|
||||
import { useToast } from "../../lib/toast";
|
||||
|
||||
interface Item {
|
||||
id: string;
|
||||
name: string;
|
||||
year?: number;
|
||||
type: string;
|
||||
poster_url: string | null;
|
||||
has_primary: boolean;
|
||||
has_logo: boolean;
|
||||
has_backdrop: boolean;
|
||||
can_bulk_assign: boolean;
|
||||
}
|
||||
|
||||
const STUDIOS = ["netflix", "appletv", "paramountplus", "hbo", "disney", "hulu"];
|
||||
|
||||
export default function BulkAssign() {
|
||||
const toast = useToast();
|
||||
const [kind, setKind] = useState<"series" | "movies">("series");
|
||||
const [query, setQuery] = useState("");
|
||||
const [items, setItems] = useState<Item[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [working, setWorking] = useState(false);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
apiGet<{ items: Item[]; total: number }>(`/api/bulk-assign/${kind}?q=${encodeURIComponent(query.trim())}&limit=60`)
|
||||
.then((d) => {
|
||||
setItems(d.items);
|
||||
setTotal(d.total);
|
||||
})
|
||||
.catch((e) => toast(e.message, "err"))
|
||||
.finally(() => setLoading(false));
|
||||
}, [kind, query, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [kind]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
function toggle(id: string) {
|
||||
setSelected((s) => {
|
||||
const n = new Set(s);
|
||||
n.has(id) ? n.delete(id) : n.add(id);
|
||||
return n;
|
||||
});
|
||||
}
|
||||
function selectAllEligible() {
|
||||
setSelected(new Set(items.filter((i) => i.can_bulk_assign).map((i) => i.id)));
|
||||
}
|
||||
|
||||
async function applySelected() {
|
||||
const ids = [...selected];
|
||||
if (!ids.length) return;
|
||||
setWorking(true);
|
||||
try {
|
||||
const res = await apiPost("/api/bulk-assign/apply", { item_ids: ids });
|
||||
toast(`Applied ${res.applied_count}, skipped ${res.skipped_missing_assets_count}, failed ${res.failed_count}`, "ok");
|
||||
setSelected(new Set());
|
||||
} catch (e: any) {
|
||||
toast(e.message, "err");
|
||||
} finally {
|
||||
setWorking(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function applyAll() {
|
||||
if (!window.confirm(`Apply thumbnails to ALL eligible ${kind} in your library? This may take a while.`)) return;
|
||||
setWorking(true);
|
||||
try {
|
||||
const res = await apiPost("/api/bulk-assign/apply-all", { item_type: kind === "series" ? "series" : "movie" });
|
||||
toast(`Applied ${res.applied_count} of ${res.eligible_count} eligible`, "ok");
|
||||
} catch (e: any) {
|
||||
toast(e.message, "err");
|
||||
} finally {
|
||||
setWorking(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function resetStudio(studio: string) {
|
||||
if (!window.confirm(`Reset Emby Thumb & Primary images for all ${studio} titles? Emby will re-download originals.`)) return;
|
||||
setWorking(true);
|
||||
try {
|
||||
const res = await apiPost("/api/bulk-reset/studio", { studio_key: studio });
|
||||
toast(`Reset ${res.reset} titles (${res.skipped} skipped)`, "ok");
|
||||
} catch (e: any) {
|
||||
toast(e.message, "err");
|
||||
} finally {
|
||||
setWorking(false);
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<div className="row between wrap" style={{ marginBottom: 16, gap: 12 }}>
|
||||
<div className="row gap-sm wrap">
|
||||
<div className="seg">
|
||||
<button className={`seg-btn ${kind === "series" ? "active" : ""}`} onClick={() => setKind("series")}>
|
||||
Series
|
||||
</button>
|
||||
<button className={`seg-btn ${kind === "movies" ? "active" : ""}`} onClick={() => setKind("movies")}>
|
||||
Movies
|
||||
</button>
|
||||
</div>
|
||||
<form
|
||||
className="search-inner"
|
||||
style={{ width: 240 }}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
load();
|
||||
}}
|
||||
>
|
||||
<IconSearch />
|
||||
<input className="input" placeholder="Filter…" value={query} onChange={(e) => setQuery(e.target.value)} />
|
||||
</form>
|
||||
</div>
|
||||
<div className="row gap-sm wrap">
|
||||
<button className="btn" onClick={selectAllEligible}>
|
||||
Select eligible
|
||||
</button>
|
||||
{selected.size > 0 && (
|
||||
<button className="btn btn-primary" onClick={applySelected} disabled={working}>
|
||||
{working ? <span className="spinner" /> : <IconCheck />} Apply {selected.size}
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-green" onClick={applyAll} disabled={working}>
|
||||
Apply all eligible
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hint" style={{ marginBottom: 14 }}>
|
||||
{total} {kind} · {items.filter((i) => i.can_bulk_assign).length} eligible on this page
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<Loading />
|
||||
) : !items.length ? (
|
||||
<div className="panel">
|
||||
<Empty icon={<IconGrid />}>No titles found.</Empty>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card-grid" style={{ gridTemplateColumns: "repeat(auto-fill, minmax(150px, 1fr))" }}>
|
||||
{items.map((it) => (
|
||||
<div
|
||||
key={it.id}
|
||||
className={`media-card ${selected.has(it.id) ? "selected" : ""}`}
|
||||
onClick={() => it.can_bulk_assign && toggle(it.id)}
|
||||
style={{ opacity: it.can_bulk_assign ? 1 : 0.55, cursor: it.can_bulk_assign ? "pointer" : "default" }}
|
||||
>
|
||||
{it.poster_url ? (
|
||||
<img className="media-cover poster" src={it.poster_url} loading="lazy" alt="" />
|
||||
) : (
|
||||
<div className="media-cover poster" />
|
||||
)}
|
||||
<div className="media-body">
|
||||
<div className="media-title">{it.name}</div>
|
||||
<div className="row wrap" style={{ gap: 4, marginTop: 5 }}>
|
||||
<span className={`badge ${it.has_logo ? "badge-ok" : "badge-bad"}`} style={{ padding: "1px 6px" }}>
|
||||
logo
|
||||
</span>
|
||||
<span className={`badge ${it.has_backdrop ? "badge-ok" : "badge-bad"}`} style={{ padding: "1px 6px" }}>
|
||||
bd
|
||||
</span>
|
||||
{selected.has(it.id) && <span className="badge badge-accent" style={{ padding: "1px 6px" }}>✓</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="panel" style={{ marginTop: 24 }}>
|
||||
<div className="panel-head">
|
||||
<h3>Reset studio artwork</h3>
|
||||
<span className="sub">Delete generated Thumb/Primary so Emby re-downloads originals</span>
|
||||
</div>
|
||||
<div className="panel-body row wrap" style={{ gap: 8 }}>
|
||||
{STUDIOS.map((s) => (
|
||||
<button key={s} className="btn btn-sm btn-danger" onClick={() => resetStudio(s)} disabled={working}>
|
||||
<IconRefresh /> {s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { apiGet, apiPost, apiPostImage } from "../../api";
|
||||
import { PageHead, Empty, Loading } from "../../components/ui";
|
||||
import { IconCheck, IconLayers, IconSearch, IconWand } from "../../components/icons";
|
||||
import { useToast } from "../../lib/toast";
|
||||
|
||||
interface Collection {
|
||||
id: string;
|
||||
name: string;
|
||||
child_count: number;
|
||||
poster_url: string | null;
|
||||
}
|
||||
|
||||
interface Opts {
|
||||
target_type: string;
|
||||
text: string;
|
||||
text_color: string;
|
||||
text_align: string;
|
||||
text_position: string;
|
||||
text_scale: number;
|
||||
darkness: number;
|
||||
}
|
||||
|
||||
const DEFAULTS: Opts = {
|
||||
target_type: "Thumb",
|
||||
text: "",
|
||||
text_color: "#FFFFFF",
|
||||
text_align: "center",
|
||||
text_position: "bottom",
|
||||
text_scale: 1.0,
|
||||
darkness: 0.18,
|
||||
};
|
||||
|
||||
export default function Collections() {
|
||||
const toast = useToast();
|
||||
const [query, setQuery] = useState("");
|
||||
const [list, setList] = useState<Collection[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selected, setSelected] = useState<Collection | null>(null);
|
||||
const [opts, setOpts] = useState<Opts>(DEFAULTS);
|
||||
const [preview, setPreview] = useState<string | null>(null);
|
||||
const [rendering, setRendering] = useState(false);
|
||||
const [applying, setApplying] = useState(false);
|
||||
const debounceRef = useRef<number>();
|
||||
|
||||
const set = <K extends keyof Opts>(k: K, v: Opts[K]) => setOpts((o) => ({ ...o, [k]: v }));
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
apiGet<{ items: Collection[] }>(`/api/collections?q=${encodeURIComponent(query.trim())}&limit=60`)
|
||||
.then((d) => setList(d.items))
|
||||
.catch((e) => toast(e.message, "err"))
|
||||
.finally(() => setLoading(false));
|
||||
}, [query, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
function select(c: Collection) {
|
||||
setSelected(c);
|
||||
setPreview(null);
|
||||
setOpts({ ...DEFAULTS, text: c.name });
|
||||
}
|
||||
|
||||
const generate = useCallback(async () => {
|
||||
if (!selected) return;
|
||||
setRendering(true);
|
||||
try {
|
||||
const { url } = await apiPostImage("/api/collections/generate", { item_id: selected.id, ...opts });
|
||||
setPreview((p) => {
|
||||
if (p) URL.revokeObjectURL(p);
|
||||
return url;
|
||||
});
|
||||
} catch (e: any) {
|
||||
toast(e.message, "err");
|
||||
} finally {
|
||||
setRendering(false);
|
||||
}
|
||||
}, [selected, opts, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected) return;
|
||||
window.clearTimeout(debounceRef.current);
|
||||
debounceRef.current = window.setTimeout(generate, 350);
|
||||
return () => window.clearTimeout(debounceRef.current);
|
||||
}, [selected, opts]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
async function apply() {
|
||||
if (!selected) return;
|
||||
setApplying(true);
|
||||
try {
|
||||
await apiPost("/api/collections/apply", { item_id: selected.id, ...opts });
|
||||
toast(`Applied ${opts.target_type} to ${selected.name}`, "ok");
|
||||
} catch (e: any) {
|
||||
toast(e.message, "err");
|
||||
} finally {
|
||||
setApplying(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title="Collection Art">Generate cover artwork for your Emby collections with custom titling.</PageHead>
|
||||
|
||||
<div className="workbench">
|
||||
<div className="panel" style={{ display: "flex", flexDirection: "column", maxHeight: "calc(100vh - 200px)" }}>
|
||||
<div style={{ padding: 14, borderBottom: "1px solid var(--border)" }}>
|
||||
<form
|
||||
className="search-inner"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
load();
|
||||
}}
|
||||
>
|
||||
<IconSearch />
|
||||
<input className="input" placeholder="Search collections…" value={query} onChange={(e) => setQuery(e.target.value)} />
|
||||
</form>
|
||||
</div>
|
||||
<div style={{ overflowY: "auto", padding: 8 }}>
|
||||
{loading ? (
|
||||
<Loading />
|
||||
) : !list.length ? (
|
||||
<Empty icon={<IconLayers />}>No collections found.</Empty>
|
||||
) : (
|
||||
list.map((c) => (
|
||||
<div key={c.id} className={`result-item ${selected?.id === c.id ? "active" : ""}`} onClick={() => select(c)}>
|
||||
{c.poster_url ? <img className="result-poster" src={c.poster_url} loading="lazy" alt="" /> : <div className="result-poster" />}
|
||||
<div className="grow">
|
||||
<div className="result-name">{c.name}</div>
|
||||
<div className="result-sub">{c.child_count} items</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="preview-shell">
|
||||
{!selected ? (
|
||||
<Empty icon={<IconLayers />}>Select a collection to design its artwork.</Empty>
|
||||
) : (
|
||||
<>
|
||||
<div className="preview-frame" style={{ aspectRatio: opts.target_type === "Primary" ? "2 / 3" : "16 / 9", maxWidth: opts.target_type === "Primary" ? 360 : "100%" }}>
|
||||
{preview ? <img src={preview} alt="preview" /> : <Loading />}
|
||||
</div>
|
||||
<div className="row" style={{ width: "100%" }}>
|
||||
<button className="btn grow" onClick={generate} disabled={rendering}>
|
||||
<IconWand /> Regenerate
|
||||
</button>
|
||||
<button className="btn btn-primary grow" onClick={apply} disabled={applying || !preview}>
|
||||
{applying ? <span className="spinner" /> : <IconCheck />} Apply
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="panel scroll-col" style={{ maxHeight: "calc(100vh - 200px)" }}>
|
||||
<div className="panel-head">
|
||||
<h3>Controls</h3>
|
||||
</div>
|
||||
<div className="panel-body col" style={{ gap: 16 }}>
|
||||
{!selected ? (
|
||||
<p className="hint">Pick a collection first.</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="field">
|
||||
<label className="field-label">Target image</label>
|
||||
<div className="seg">
|
||||
<button className={`seg-btn ${opts.target_type === "Thumb" ? "active" : ""}`} onClick={() => set("target_type", "Thumb")}>
|
||||
Thumb
|
||||
</button>
|
||||
<button className={`seg-btn ${opts.target_type === "Primary" ? "active" : ""}`} onClick={() => set("target_type", "Primary")}>
|
||||
Primary
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="field-label">Title text</label>
|
||||
<input className="input" value={opts.text} onChange={(e) => set("text", e.target.value)} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="field-label">Text align</label>
|
||||
<div className="seg">
|
||||
{["left", "center", "right"].map((a) => (
|
||||
<button key={a} className={`seg-btn ${opts.text_align === a ? "active" : ""}`} onClick={() => set("text_align", a)}>
|
||||
{a}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="field-label">Text position</label>
|
||||
<div className="seg">
|
||||
{["top", "center", "bottom"].map((p) => (
|
||||
<button key={p} className={`seg-btn ${opts.text_position === p ? "active" : ""}`} onClick={() => set("text_position", p)}>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="field-label">
|
||||
Text scale <span className="mono">{opts.text_scale.toFixed(2)}×</span>
|
||||
</label>
|
||||
<input type="range" min={0.65} max={1.8} step={0.05} value={opts.text_scale} onChange={(e) => set("text_scale", +e.target.value)} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="field-label">
|
||||
Darkness <span className="mono">{Math.round(opts.darkness * 100)}%</span>
|
||||
</label>
|
||||
<input type="range" min={0} max={0.85} step={0.05} value={opts.darkness} onChange={(e) => set("darkness", +e.target.value)} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="field-label">Text color</label>
|
||||
<div className="row">
|
||||
<input type="color" value={opts.text_color} onChange={(e) => set("text_color", e.target.value)} />
|
||||
<input className="input grow" value={opts.text_color} onChange={(e) => set("text_color", e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { apiGet, apiPost } from "../../api";
|
||||
import { PageHead, Empty, Loading } from "../../components/ui";
|
||||
import { IconHeart, IconRefresh, IconTrash, IconUser } from "../../components/icons";
|
||||
import { useToast } from "../../lib/toast";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
interface Collection {
|
||||
collection_id: string;
|
||||
collection_name: string;
|
||||
owner_name: string | null;
|
||||
is_favorites: boolean;
|
||||
item_count: number | null;
|
||||
owner_user_id: string | null;
|
||||
}
|
||||
interface ViewItem {
|
||||
id: string;
|
||||
title: string;
|
||||
type: string;
|
||||
year?: number;
|
||||
watched: boolean;
|
||||
}
|
||||
interface View {
|
||||
collection_name: string;
|
||||
user_name: string;
|
||||
items: ViewItem[];
|
||||
summary: { current_count: number; watched_count: number; unwatched_count: number };
|
||||
}
|
||||
|
||||
export default function Favorites() {
|
||||
const toast = useToast();
|
||||
const [collections, setCollections] = useState<Collection[]>([]);
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [collectionId, setCollectionId] = useState("");
|
||||
const [userId, setUserId] = useState("");
|
||||
const [view, setView] = useState<View | null>(null);
|
||||
const [viewLoading, setViewLoading] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [targetSize, setTargetSize] = useState(20);
|
||||
|
||||
useEffect(() => {
|
||||
apiGet<{ collections: Collection[]; users: User[] }>("/api/favorites/collections")
|
||||
.then((d) => {
|
||||
setCollections(d.collections);
|
||||
setUsers(d.users);
|
||||
})
|
||||
.catch((e) => toast(e.message, "err"))
|
||||
.finally(() => setLoading(false));
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// When a favorites collection is picked, default the user to its owner.
|
||||
useEffect(() => {
|
||||
const c = collections.find((x) => x.collection_id === collectionId);
|
||||
if (c?.owner_user_id) setUserId(c.owner_user_id);
|
||||
}, [collectionId, collections]);
|
||||
|
||||
function loadView() {
|
||||
if (!collectionId || !userId) return;
|
||||
setViewLoading(true);
|
||||
setView(null);
|
||||
apiGet<View>(`/api/favorites/collection/${collectionId}?user_id=${userId}`)
|
||||
.then(setView)
|
||||
.catch((e) => toast(e.message, "err"))
|
||||
.finally(() => setViewLoading(false));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (collectionId && userId) loadView();
|
||||
}, [collectionId, userId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
async function cleanup(dryRun: boolean) {
|
||||
if (!dryRun && !window.confirm("Remove all watched items from this collection?")) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await apiPost(`/api/favorites/collection/${collectionId}/cleanup`, { userId, dryRun });
|
||||
toast(
|
||||
dryRun
|
||||
? `${res.summary.watched_count} watched items would be removed`
|
||||
: `Removed ${res.summary.removed_count} watched items`,
|
||||
dryRun ? "info" : "ok"
|
||||
);
|
||||
if (!dryRun) loadView();
|
||||
} catch (e: any) {
|
||||
toast(e.message, "err");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function regenerate(dryRun: boolean) {
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await apiPost(`/api/favorites/collection/${collectionId}/regenerate`, { userId, dryRun, targetSize });
|
||||
toast(
|
||||
dryRun
|
||||
? `${res.summary.recommended_count} recommendations available`
|
||||
: `Added ${res.summary.added_count} recommendations`,
|
||||
dryRun ? "info" : "ok"
|
||||
);
|
||||
if (!dryRun) loadView();
|
||||
} catch (e: any) {
|
||||
toast(e.message, "err");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <Loading />;
|
||||
|
||||
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>
|
||||
|
||||
<div className="panel" style={{ marginBottom: 18 }}>
|
||||
<div className="panel-body row wrap" style={{ gap: 14 }}>
|
||||
<div className="field grow" style={{ minWidth: 220 }}>
|
||||
<label className="field-label">Collection</label>
|
||||
<select className="select" value={collectionId} onChange={(e) => setCollectionId(e.target.value)}>
|
||||
<option value="">Select a collection…</option>
|
||||
{collections.map((c) => (
|
||||
<option key={c.collection_id} value={c.collection_id}>
|
||||
{c.collection_name} {c.is_favorites ? "★" : ""} ({c.item_count ?? "?"})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field grow" style={{ minWidth: 200 }}>
|
||||
<label className="field-label">User (for watched status)</label>
|
||||
<select className="select" value={userId} onChange={(e) => setUserId(e.target.value)}>
|
||||
<option value="">Select a user…</option>
|
||||
{users.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{viewLoading ? (
|
||||
<Loading />
|
||||
) : !view ? (
|
||||
<div className="panel">
|
||||
<Empty icon={<IconHeart />}>Pick a collection and user to inspect favorites.</Empty>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="row between wrap" style={{ marginBottom: 16, gap: 12 }}>
|
||||
<div className="row gap-sm wrap">
|
||||
<span className="badge">{view.summary.current_count} items</span>
|
||||
<span className="badge badge-ok">{view.summary.watched_count} watched</span>
|
||||
<span className="badge badge-accent">{view.summary.unwatched_count} unwatched</span>
|
||||
</div>
|
||||
<div className="row gap-sm wrap">
|
||||
<button className="btn btn-sm" onClick={() => cleanup(true)} disabled={busy}>
|
||||
Preview cleanup
|
||||
</button>
|
||||
<button className="btn btn-sm btn-danger" onClick={() => cleanup(false)} disabled={busy}>
|
||||
<IconTrash /> Remove watched
|
||||
</button>
|
||||
<div className="row gap-sm" style={{ marginLeft: 8 }}>
|
||||
<span className="hint">Target</span>
|
||||
<input
|
||||
className="input"
|
||||
style={{ width: 64 }}
|
||||
type="number"
|
||||
min={0}
|
||||
value={targetSize}
|
||||
onChange={(e) => setTargetSize(+e.target.value)}
|
||||
/>
|
||||
<button className="btn btn-sm" onClick={() => regenerate(true)} disabled={busy}>
|
||||
Preview recs
|
||||
</button>
|
||||
<button className="btn btn-sm btn-green" onClick={() => regenerate(false)} disabled={busy}>
|
||||
<IconRefresh /> Add recs
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="panel">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th>Type</th>
|
||||
<th>Year</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{view.items.map((i) => (
|
||||
<tr key={i.id}>
|
||||
<td className="cell-strong">{i.title}</td>
|
||||
<td>{i.type}</td>
|
||||
<td className="mono">{i.year || "—"}</td>
|
||||
<td>
|
||||
{i.watched ? <span className="badge badge-ok">watched</span> : <span className="badge">unwatched</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { apiGet, apiPost, apiPostImage, uploadBackground, SearchItem } from "../../api";
|
||||
import { PageHead, Empty, Loading } from "../../components/ui";
|
||||
import { IconCheck, IconImage, IconSearch, IconUpload, IconWand } from "../../components/icons";
|
||||
import { useToast } from "../../lib/toast";
|
||||
|
||||
interface ImageInfo {
|
||||
has_logo: boolean;
|
||||
logo_count: number;
|
||||
backdrop_count: number;
|
||||
logos: { index: number; url: string }[];
|
||||
backdrops: { index: number }[];
|
||||
}
|
||||
|
||||
interface Options {
|
||||
title: string;
|
||||
bg_mode: string;
|
||||
backdrop_index: number;
|
||||
text_color: string;
|
||||
logo_align: string;
|
||||
logo_scale: number;
|
||||
darkness: number;
|
||||
studio: string;
|
||||
studio_position: string;
|
||||
new_episodes_tag: boolean;
|
||||
season_finale_tag: boolean;
|
||||
generate_primary: boolean;
|
||||
logo_index: number;
|
||||
upload_bg_id: string | null;
|
||||
}
|
||||
|
||||
const DEFAULTS: Options = {
|
||||
title: "",
|
||||
bg_mode: "backdrop",
|
||||
backdrop_index: 0,
|
||||
text_color: "#FFFFFF",
|
||||
logo_align: "bottom-center",
|
||||
logo_scale: 1.3,
|
||||
darkness: 0.0,
|
||||
studio: "auto",
|
||||
studio_position: "top-left",
|
||||
new_episodes_tag: false,
|
||||
season_finale_tag: false,
|
||||
generate_primary: false,
|
||||
logo_index: 0,
|
||||
upload_bg_id: null,
|
||||
};
|
||||
|
||||
const ALIGNS = ["top-left", "top-center", "top-right", "bottom-left", "bottom-center", "bottom-right"];
|
||||
const STUDIOS = ["auto", "none", "netflix", "appletv", "paramountplus", "hbo", "disney", "hulu"];
|
||||
const POSITIONS = ["top-left", "top-right", "bottom-left", "bottom-right"];
|
||||
|
||||
export default function Generator() {
|
||||
const toast = useToast();
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<SearchItem[]>([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [selected, setSelected] = useState<SearchItem | null>(null);
|
||||
const [imageInfo, setImageInfo] = useState<ImageInfo | null>(null);
|
||||
const [opts, setOpts] = useState<Options>(DEFAULTS);
|
||||
const [preview, setPreview] = useState<string | null>(null);
|
||||
const [rendering, setRendering] = useState(false);
|
||||
const [applying, setApplying] = useState(false);
|
||||
const debounceRef = useRef<number>();
|
||||
|
||||
const set = <K extends keyof Options>(k: K, v: Options[K]) => setOpts((o) => ({ ...o, [k]: v }));
|
||||
|
||||
async function search(e?: React.FormEvent) {
|
||||
e?.preventDefault();
|
||||
if (!query.trim()) return;
|
||||
setSearching(true);
|
||||
try {
|
||||
const d = await apiGet<{ items: SearchItem[] }>(
|
||||
`/api/search?q=${encodeURIComponent(query.trim())}&limit=40`
|
||||
);
|
||||
setResults(d.items);
|
||||
} catch (err: any) {
|
||||
toast(err.message, "err");
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function select(item: SearchItem) {
|
||||
setSelected(item);
|
||||
setPreview(null);
|
||||
setImageInfo(null);
|
||||
setOpts({ ...DEFAULTS, title: item.name, studio: item.auto_studio || "auto" });
|
||||
try {
|
||||
const info = await apiGet<ImageInfo>(`/api/images/${item.id}`);
|
||||
setImageInfo(info);
|
||||
} catch (err: any) {
|
||||
toast(err.message, "err");
|
||||
}
|
||||
}
|
||||
|
||||
const buildBody = useCallback(
|
||||
() => ({ item_id: selected?.id, ...opts }),
|
||||
[selected, opts]
|
||||
);
|
||||
|
||||
const generate = useCallback(async () => {
|
||||
if (!selected) return;
|
||||
setRendering(true);
|
||||
try {
|
||||
const { url } = await apiPostImage("/api/generate", buildBody());
|
||||
setPreview((prev) => {
|
||||
if (prev) URL.revokeObjectURL(prev);
|
||||
return url;
|
||||
});
|
||||
} catch (err: any) {
|
||||
toast(err.message, "err");
|
||||
} finally {
|
||||
setRendering(false);
|
||||
}
|
||||
}, [selected, buildBody, toast]);
|
||||
|
||||
// Auto-regenerate the preview shortly after any option changes.
|
||||
useEffect(() => {
|
||||
if (!selected) return;
|
||||
window.clearTimeout(debounceRef.current);
|
||||
debounceRef.current = window.setTimeout(generate, 350);
|
||||
return () => window.clearTimeout(debounceRef.current);
|
||||
}, [selected, opts]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
async function apply() {
|
||||
if (!selected) return;
|
||||
setApplying(true);
|
||||
try {
|
||||
const res = await apiPost("/api/apply", buildBody());
|
||||
toast(`Applied to Emby (thumb ${res.thumb_code})`, "ok");
|
||||
} catch (err: any) {
|
||||
toast(err.message, "err");
|
||||
} finally {
|
||||
setApplying(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function onUpload(file: File) {
|
||||
try {
|
||||
const { upload_id } = await uploadBackground(file);
|
||||
setOpts((o) => ({ ...o, bg_mode: "upload", upload_bg_id: upload_id }));
|
||||
toast("Background uploaded", "ok");
|
||||
} catch (err: any) {
|
||||
toast(err.message, "err");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title="Thumbnail Generator">
|
||||
Composite a landscape thumbnail from an item's poster, logo and backdrop, then push it back to Emby.
|
||||
</PageHead>
|
||||
|
||||
<div className="workbench">
|
||||
{/* search column */}
|
||||
<div className="panel" style={{ display: "flex", flexDirection: "column", maxHeight: "calc(100vh - 200px)" }}>
|
||||
<div style={{ padding: 14, borderBottom: "1px solid var(--border)" }}>
|
||||
<form className="search-inner" onSubmit={search}>
|
||||
<IconSearch />
|
||||
<input className="input" placeholder="Search movies & series…" value={query} onChange={(e) => setQuery(e.target.value)} />
|
||||
</form>
|
||||
</div>
|
||||
<div style={{ overflowY: "auto", padding: 8 }}>
|
||||
{searching ? (
|
||||
<Loading />
|
||||
) : results.length === 0 ? (
|
||||
<Empty icon={<IconSearch />}>Search your Emby library to begin.</Empty>
|
||||
) : (
|
||||
results.map((r) => (
|
||||
<div key={r.id} className={`result-item ${selected?.id === r.id ? "active" : ""}`} onClick={() => select(r)}>
|
||||
<img className="result-poster" src={r.poster_url} loading="lazy" alt="" />
|
||||
<div className="grow">
|
||||
<div className="result-name">{r.name}</div>
|
||||
<div className="result-sub">
|
||||
<span>{r.year || "—"}</span>
|
||||
<span>·</span>
|
||||
<span>{r.type}</span>
|
||||
{r.has_logo && <span className="badge badge-ok">logo</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* preview column */}
|
||||
<div className="preview-shell">
|
||||
{!selected ? (
|
||||
<Empty icon={<IconImage />}>Select an item to preview a thumbnail.</Empty>
|
||||
) : (
|
||||
<>
|
||||
<div className="preview-frame" style={{ aspectRatio: "16 / 9", position: "relative" }}>
|
||||
{preview ? <img src={preview} alt="preview" /> : <Loading />}
|
||||
{rendering && preview && (
|
||||
<div style={{ position: "absolute", top: 10, right: 10 }}>
|
||||
<span className="spinner" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="row" style={{ width: "100%" }}>
|
||||
<button className="btn grow" onClick={generate} disabled={rendering}>
|
||||
<IconWand /> Regenerate
|
||||
</button>
|
||||
<button className="btn btn-primary grow" onClick={apply} disabled={applying || !preview}>
|
||||
{applying ? <span className="spinner" /> : <IconCheck />} Apply to Emby
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* controls column */}
|
||||
<div className="panel scroll-col" style={{ maxHeight: "calc(100vh - 200px)" }}>
|
||||
<div className="panel-head">
|
||||
<h3>Controls</h3>
|
||||
</div>
|
||||
<div className="panel-body col" style={{ gap: 16 }}>
|
||||
{!selected ? (
|
||||
<p className="hint">Pick an item first.</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="field">
|
||||
<label className="field-label">Title</label>
|
||||
<input className="input" value={opts.title} onChange={(e) => set("title", e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label className="field-label">Background</label>
|
||||
<div className="seg">
|
||||
<button className={`seg-btn ${opts.bg_mode === "backdrop" ? "active" : ""}`} onClick={() => set("bg_mode", "backdrop")}>
|
||||
Backdrop
|
||||
</button>
|
||||
<button className={`seg-btn ${opts.bg_mode === "upload" ? "active" : ""}`} onClick={() => opts.upload_bg_id && set("bg_mode", "upload")} disabled={!opts.upload_bg_id}>
|
||||
Upload
|
||||
</button>
|
||||
</div>
|
||||
<label className="btn btn-sm" style={{ marginTop: 4 }}>
|
||||
<IconUpload /> Upload background
|
||||
<input type="file" accept="image/*" hidden onChange={(e) => e.target.files?.[0] && onUpload(e.target.files[0])} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{opts.bg_mode === "backdrop" && imageInfo && imageInfo.backdrop_count > 1 && (
|
||||
<div className="field">
|
||||
<label className="field-label">
|
||||
Backdrop <span>{opts.backdrop_index + 1}/{imageInfo.backdrop_count}</span>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={imageInfo.backdrop_count - 1}
|
||||
value={opts.backdrop_index}
|
||||
onChange={(e) => set("backdrop_index", +e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{imageInfo && imageInfo.logo_count > 1 && (
|
||||
<div className="field">
|
||||
<label className="field-label">
|
||||
Logo <span>{opts.logo_index + 1}/{imageInfo.logo_count}</span>
|
||||
</label>
|
||||
<input type="range" min={0} max={imageInfo.logo_count - 1} value={opts.logo_index} onChange={(e) => set("logo_index", +e.target.value)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="field">
|
||||
<label className="field-label">Logo position</label>
|
||||
<div className="seg">
|
||||
{ALIGNS.map((a) => (
|
||||
<button key={a} className={`seg-btn ${opts.logo_align === a ? "active" : ""}`} onClick={() => set("logo_align", a)}>
|
||||
{a.replace("-", " ")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label className="field-label">
|
||||
Logo scale <span className="mono">{opts.logo_scale.toFixed(2)}×</span>
|
||||
</label>
|
||||
<input type="range" min={0.5} max={2.5} step={0.05} value={opts.logo_scale} onChange={(e) => set("logo_scale", +e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label className="field-label">
|
||||
Darkness <span className="mono">{Math.round(opts.darkness * 100)}%</span>
|
||||
</label>
|
||||
<input type="range" min={0} max={1} step={0.05} value={opts.darkness} onChange={(e) => set("darkness", +e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label className="field-label">Text color</label>
|
||||
<div className="row">
|
||||
<input type="color" value={opts.text_color} onChange={(e) => set("text_color", e.target.value)} />
|
||||
<input className="input grow" value={opts.text_color} onChange={(e) => set("text_color", e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label className="field-label">Studio logo</label>
|
||||
<select className="select" value={opts.studio} onChange={(e) => set("studio", e.target.value)}>
|
||||
{STUDIOS.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{opts.studio !== "none" && (
|
||||
<div className="field">
|
||||
<label className="field-label">Studio position</label>
|
||||
<div className="seg">
|
||||
{POSITIONS.map((p) => (
|
||||
<button key={p} className={`seg-btn ${opts.studio_position === p ? "active" : ""}`} onClick={() => set("studio_position", p)}>
|
||||
{p.replace("-", " ")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected.type === "Series" && (
|
||||
<div className="row wrap">
|
||||
<button className={`chip ${opts.new_episodes_tag ? "active" : ""}`} onClick={() => set("new_episodes_tag", !opts.new_episodes_tag)}>
|
||||
New episodes
|
||||
</button>
|
||||
<button className={`chip ${opts.season_finale_tag ? "active" : ""}`} onClick={() => set("season_finale_tag", !opts.season_finale_tag)}>
|
||||
Season finale
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { apiGet, apiPost } from "../../api";
|
||||
import { PageHead, StatCard, Loading, Empty, timeAgo } from "../../components/ui";
|
||||
import {
|
||||
IconCheck,
|
||||
IconChevron,
|
||||
IconDisc,
|
||||
IconImage,
|
||||
IconLayers,
|
||||
IconRefresh,
|
||||
IconSearch,
|
||||
IconWand,
|
||||
} from "../../components/icons";
|
||||
import { useToast } from "../../lib/toast";
|
||||
|
||||
interface Overview {
|
||||
last_scan: any | null;
|
||||
last_metadata: any | null;
|
||||
scan_running: boolean;
|
||||
metadata_running: boolean;
|
||||
owned: number;
|
||||
missing: number;
|
||||
uncertain: number;
|
||||
ignored: number;
|
||||
completeness: number;
|
||||
library_artists: number;
|
||||
library_albums: number;
|
||||
}
|
||||
interface ArtistRow {
|
||||
id: number;
|
||||
name: string;
|
||||
owned: number;
|
||||
missing: number;
|
||||
uncertain: number;
|
||||
ignored: number;
|
||||
completeness: number;
|
||||
}
|
||||
interface Album {
|
||||
id: number;
|
||||
title: string;
|
||||
year: number;
|
||||
status: string;
|
||||
confidence: number;
|
||||
reason: string;
|
||||
source: string;
|
||||
manual_override: number;
|
||||
}
|
||||
|
||||
const STATUS_BADGE: Record<string, string> = {
|
||||
owned: "badge-ok",
|
||||
probably_owned: "badge-ok",
|
||||
missing: "badge-bad",
|
||||
uncertain: "badge-warn",
|
||||
ignored: "badge",
|
||||
};
|
||||
const statusLabel = (s: string) => s.replace("_", " ");
|
||||
|
||||
export default function CollectionCompleteness() {
|
||||
const toast = useToast();
|
||||
const [overview, setOverview] = useState<Overview | null>(null);
|
||||
const [artists, setArtists] = useState<ArtistRow[]>([]);
|
||||
const [search, setSearch] = useState("");
|
||||
const [expanded, setExpanded] = useState<number | null>(null);
|
||||
const [albums, setAlbums] = useState<Record<number, Album[]>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const pollRef = useRef<number>();
|
||||
|
||||
const loadOverview = useCallback(() => {
|
||||
return apiGet<Overview>("/api/music-collection/overview")
|
||||
.then(setOverview)
|
||||
.catch(() => setOverview(null));
|
||||
}, []);
|
||||
|
||||
const loadArtists = useCallback(
|
||||
(q = "") =>
|
||||
apiGet<{ artists: ArtistRow[] }>(`/api/music-collection/artists?q=${encodeURIComponent(q)}`)
|
||||
.then((d) => setArtists(d.artists))
|
||||
.catch(() => setArtists([])),
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([loadOverview(), loadArtists()]).finally(() => setLoading(false));
|
||||
}, [loadOverview, loadArtists]);
|
||||
|
||||
// Poll while a job runs; refresh data when it finishes.
|
||||
const running = !!overview && (overview.scan_running || overview.metadata_running);
|
||||
useEffect(() => {
|
||||
if (!running) {
|
||||
window.clearInterval(pollRef.current);
|
||||
return;
|
||||
}
|
||||
pollRef.current = window.setInterval(async () => {
|
||||
const prev = running;
|
||||
await loadOverview();
|
||||
const next = overview && (overview.scan_running || overview.metadata_running);
|
||||
if (prev && !next) loadArtists(search);
|
||||
}, 2500);
|
||||
return () => window.clearInterval(pollRef.current);
|
||||
}, [running]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
async function startScan() {
|
||||
const res = await apiPost<{ started: boolean; reason?: string }>("/api/music-collection/scan");
|
||||
toast(res.started ? "Library scan started" : res.reason || "Already running", res.started ? "ok" : "info");
|
||||
loadOverview();
|
||||
}
|
||||
async function refreshMetadata() {
|
||||
const res = await apiPost<{ started: boolean; reason?: string }>("/api/music-collection/refresh-metadata");
|
||||
toast(res.started ? "Metadata refresh started" : res.reason || "Already running", res.started ? "ok" : "info");
|
||||
loadOverview();
|
||||
}
|
||||
|
||||
function toggleArtist(id: number) {
|
||||
if (expanded === id) {
|
||||
setExpanded(null);
|
||||
return;
|
||||
}
|
||||
setExpanded(id);
|
||||
if (!albums[id]) {
|
||||
apiGet<{ albums: Album[] }>(`/api/music-collection/artist/${id}/albums`)
|
||||
.then((d) => setAlbums((a) => ({ ...a, [id]: d.albums })))
|
||||
.catch((e) => toast(e.message, "err"));
|
||||
}
|
||||
}
|
||||
|
||||
async function decide(artistId: number, albumId: number, action: string) {
|
||||
try {
|
||||
await apiPost(`/api/music-collection/album/${albumId}/decision`, { action });
|
||||
const d = await apiGet<{ albums: Album[] }>(`/api/music-collection/artist/${artistId}/albums`);
|
||||
setAlbums((a) => ({ ...a, [artistId]: d.albums }));
|
||||
loadOverview();
|
||||
loadArtists(search);
|
||||
} catch (e: any) {
|
||||
toast(e.message, "err");
|
||||
}
|
||||
}
|
||||
|
||||
const o = overview;
|
||||
const scanStatus = o?.scan_running
|
||||
? "Scanning…"
|
||||
: o?.last_scan
|
||||
? `${o.last_scan.status} · ${timeAgo(o.last_scan.completed_at || o.last_scan.started_at)}`
|
||||
: "Never run";
|
||||
|
||||
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>
|
||||
<div className="row gap-sm">
|
||||
<button className="btn" onClick={startScan} disabled={o?.scan_running}>
|
||||
{o?.scan_running ? <span className="spinner" /> : <IconRefresh />} Scan library
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={refreshMetadata} disabled={o?.metadata_running}>
|
||||
{o?.metadata_running ? <span className="spinner" /> : <IconWand />} Refresh metadata
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<>
|
||||
<div className="stat-row" style={{ marginBottom: 14 }}>
|
||||
<StatCard icon={<IconCheck />} value={`${o?.completeness ?? 0}%`} label="Overall completeness" />
|
||||
<StatCard icon={<IconLayers />} value={o?.owned ?? 0} label="Owned albums" />
|
||||
<StatCard icon={<IconImage />} value={o?.missing ?? 0} label="Missing albums" />
|
||||
<StatCard icon={<IconDisc />} value={o?.uncertain ?? 0} label="Uncertain matches" />
|
||||
</div>
|
||||
|
||||
<div className="row wrap gap-sm" style={{ marginBottom: 18 }}>
|
||||
<span className="badge">{o?.library_artists ?? 0} artists scanned</span>
|
||||
<span className="badge">{o?.library_albums ?? 0} albums in library</span>
|
||||
<span className="badge">Last scan: {scanStatus}</span>
|
||||
<span className="badge">
|
||||
Last metadata:{" "}
|
||||
{o?.metadata_running
|
||||
? `running · ${o?.last_metadata?.progress || ""}`
|
||||
: o?.last_metadata
|
||||
? timeAgo(o.last_metadata.completed_at || o.last_metadata.started_at)
|
||||
: "never"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="search-inner"
|
||||
style={{ maxWidth: 360, marginBottom: 16 }}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
loadArtists(search);
|
||||
}}
|
||||
>
|
||||
<IconSearch />
|
||||
<input className="input" placeholder="Search artists…" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
</form>
|
||||
|
||||
{artists.length === 0 ? (
|
||||
<div className="panel">
|
||||
<Empty icon={<IconDisc />}>
|
||||
No completeness data yet. Run <strong>Scan library</strong>, then <strong>Refresh metadata</strong> to
|
||||
compare against MusicBrainz.
|
||||
</Empty>
|
||||
</div>
|
||||
) : (
|
||||
<div className="panel">
|
||||
{artists.map((a) => (
|
||||
<div key={a.id} style={{ borderBottom: "1px solid var(--border)" }}>
|
||||
<button className="completeness-row" onClick={() => toggleArtist(a.id)}>
|
||||
<IconChevron className={`nav-caret ${expanded === a.id ? "open" : ""}`} />
|
||||
<span className="grow" style={{ textAlign: "left", fontWeight: 600 }}>
|
||||
{a.name}
|
||||
</span>
|
||||
<span className="badge badge-ok">{a.owned} owned</span>
|
||||
{a.missing > 0 && <span className="badge badge-bad">{a.missing} missing</span>}
|
||||
{a.uncertain > 0 && <span className="badge badge-warn">{a.uncertain} uncertain</span>}
|
||||
<span className="completeness-pct mono">{a.completeness}%</span>
|
||||
<span className="bar" style={{ width: 90 }}>
|
||||
<span className="bar-fill" style={{ width: `${a.completeness}%` }} />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{expanded === a.id && (
|
||||
<div style={{ padding: "0 16px 14px 40px" }}>
|
||||
{!albums[a.id] ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Album</th>
|
||||
<th>Year</th>
|
||||
<th>Status</th>
|
||||
<th>Confidence</th>
|
||||
<th>Source</th>
|
||||
<th style={{ textAlign: "right" }}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{albums[a.id].map((al) => (
|
||||
<tr key={al.id}>
|
||||
<td className="cell-strong" title={al.reason}>
|
||||
{al.title}
|
||||
{al.manual_override ? <span className="badge" style={{ marginLeft: 8 }}>manual</span> : null}
|
||||
</td>
|
||||
<td className="mono">{al.year || "—"}</td>
|
||||
<td>
|
||||
<span className={`badge ${STATUS_BADGE[al.status] || ""}`}>{statusLabel(al.status)}</span>
|
||||
</td>
|
||||
<td className="mono cell-sub">{al.confidence ? al.confidence.toFixed(2) : "—"}</td>
|
||||
<td className="cell-sub">{al.source}</td>
|
||||
<td>
|
||||
<div className="row gap-sm" style={{ justifyContent: "flex-end" }}>
|
||||
<button className="btn btn-sm" onClick={() => decide(a.id, al.id, "owned")}>
|
||||
Owned
|
||||
</button>
|
||||
<button className="btn btn-sm" onClick={() => decide(a.id, al.id, "missing")}>
|
||||
Missing
|
||||
</button>
|
||||
<button className="btn btn-sm" onClick={() => decide(a.id, al.id, "ignore")}>
|
||||
Ignore
|
||||
</button>
|
||||
<button className="btn btn-sm" onClick={() => decide(a.id, al.id, "reset")}>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
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 { useToast } from "../../lib/toast";
|
||||
|
||||
interface Album {
|
||||
path: string;
|
||||
folder_name: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
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;
|
||||
message: string;
|
||||
}
|
||||
|
||||
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: "rename", label: "Rename tracks", desc: "Rename audio files to 'NN - Title'", icon: <IconDisc /> },
|
||||
{ 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 [modes, setModes] = useState<Record<ModeKey, boolean>>({
|
||||
covers: true,
|
||||
folder_cleanup: false,
|
||||
rename: false,
|
||||
file_cleanup: false,
|
||||
lyrics: false,
|
||||
});
|
||||
const [actions, setActions] = useState<Action[] | null>(null);
|
||||
|
||||
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
|
||||
|
||||
async function run() {
|
||||
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;
|
||||
}
|
||||
setRunning(true);
|
||||
setActions(null);
|
||||
try {
|
||||
const res = await apiPost<{ actions: Action[]; dry_run: boolean }>("/api/music/process", {
|
||||
...modes,
|
||||
dry_run: dryRun,
|
||||
});
|
||||
setActions(res.actions);
|
||||
toast(dryRun ? "Dry run complete" : "Changes applied", dryRun ? "info" : "ok");
|
||||
if (!dryRun) refresh();
|
||||
} catch (e: any) {
|
||||
toast(e.message, "err");
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <Loading label="Scanning music library…" />;
|
||||
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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="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>
|
||||
|
||||
<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>
|
||||
|
||||
<div className="workbench" style={{ gridTemplateColumns: "340px 1fr" }}>
|
||||
<div className="col">
|
||||
<div className="panel">
|
||||
<div className="panel-head">
|
||||
<h3>Maintenance modes</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>
|
||||
))}
|
||||
</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 files touched" : "Will modify files on disk"}</div>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="panel">
|
||||
<div className="panel-head">
|
||||
<h3>Albums</h3>
|
||||
<span className="sub">{scan.albums.length}</span>
|
||||
</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>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { apiGet } from "../../api";
|
||||
import { PageHead, Empty, Loading, fmtDuration } from "../../components/ui";
|
||||
import { IconDisc, IconMusic, IconSearch, IconPlay } 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;
|
||||
track?: number;
|
||||
duration: number;
|
||||
artist: string;
|
||||
}
|
||||
interface AlbumDetail extends Album {
|
||||
songs: Song[];
|
||||
}
|
||||
|
||||
const SORTS = [
|
||||
{ key: "alphabeticalByName", label: "A–Z" },
|
||||
{ key: "newest", label: "Newest" },
|
||||
{ key: "recent", label: "Recently Played" },
|
||||
{ key: "frequent", label: "Most Played" },
|
||||
{ key: "random", label: "Random" },
|
||||
];
|
||||
|
||||
export default function Library() {
|
||||
const toast = useToast();
|
||||
const [status, setStatus] = useState<{ connected: boolean; configured: boolean; error?: string } | null>(null);
|
||||
const [albums, setAlbums] = useState<Album[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [sort, setSort] = useState("alphabeticalByName");
|
||||
const [query, setQuery] = useState("");
|
||||
const [selected, setSelected] = useState<AlbumDetail | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
apiGet("/api/navidrome/status").then(setStatus).catch(() => setStatus({ connected: false, configured: false }));
|
||||
}, []);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
const q = query.trim();
|
||||
const path = q
|
||||
? `/api/navidrome/albums?q=${encodeURIComponent(q)}&size=120`
|
||||
: `/api/navidrome/albums?type=${sort}&size=120`;
|
||||
apiGet<{ items: Album[] }>(path)
|
||||
.then((d) => setAlbums(d.items))
|
||||
.catch((e) => toast(e.message, "err"))
|
||||
.finally(() => setLoading(false));
|
||||
}, [sort, query, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status?.connected) load();
|
||||
}, [status?.connected, sort]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
function openAlbum(id: string) {
|
||||
setDetailLoading(true);
|
||||
setSelected(null);
|
||||
apiGet<AlbumDetail>(`/api/navidrome/album/${id}`)
|
||||
.then(setSelected)
|
||||
.catch((e) => toast(e.message, "err"))
|
||||
.finally(() => setDetailLoading(false));
|
||||
}
|
||||
|
||||
if (status && !status.configured) {
|
||||
return (
|
||||
<>
|
||||
<PageHead title="Music Library">Browse your Navidrome library.</PageHead>
|
||||
<div className="panel">
|
||||
<Empty icon={<IconMusic />}>
|
||||
Navidrome is not configured. Set <code>NAVIDROME_URL</code>, <code>NAVIDROME_USER</code> and{" "}
|
||||
<code>NAVIDROME_PASSWORD</code> and restart the app.
|
||||
</Empty>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (status && status.configured && !status.connected) {
|
||||
return (
|
||||
<>
|
||||
<PageHead title="Music Library">Browse your Navidrome library.</PageHead>
|
||||
<div className="panel">
|
||||
<Empty icon={<IconMusic />}>Could not connect to Navidrome. {status.error}</Empty>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title="Music Library">Browse artists and albums served by your Navidrome instance.</PageHead>
|
||||
|
||||
<div className="row between wrap" style={{ marginBottom: 18, gap: 12 }}>
|
||||
<div className="seg">
|
||||
{SORTS.map((s) => (
|
||||
<button
|
||||
key={s.key}
|
||||
className={`seg-btn ${sort === s.key && !query ? "active" : ""}`}
|
||||
onClick={() => {
|
||||
setQuery("");
|
||||
setSort(s.key);
|
||||
}}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<form
|
||||
className="search-inner"
|
||||
style={{ width: 280 }}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
load();
|
||||
}}
|
||||
>
|
||||
<IconSearch />
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Search albums…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<Loading label="Loading albums…" />
|
||||
) : albums.length === 0 ? (
|
||||
<div className="panel">
|
||||
<Empty icon={<IconDisc />}>No albums found.</Empty>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card-grid">
|
||||
{albums.map((a) => (
|
||||
<div key={a.id} className="media-card" onClick={() => openAlbum(a.id)}>
|
||||
{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>
|
||||
)}
|
||||
|
||||
{(selected || detailLoading) && (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
background: "rgba(4,7,11,0.7)",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
zIndex: 50,
|
||||
padding: 24,
|
||||
}}
|
||||
onClick={() => setSelected(null)}
|
||||
>
|
||||
<div className="panel" style={{ width: 640, maxWidth: "100%", maxHeight: "86vh", overflow: "auto" }} onClick={(e) => e.stopPropagation()}>
|
||||
{detailLoading || !selected ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<>
|
||||
<div className="panel-head">
|
||||
{selected.cover_url && (
|
||||
<img
|
||||
src={`${selected.cover_url}?size=120`}
|
||||
style={{ width: 64, height: 64, borderRadius: 10, objectFit: "cover" }}
|
||||
alt=""
|
||||
/>
|
||||
)}
|
||||
<div className="grow">
|
||||
<h3>{selected.name}</h3>
|
||||
<div className="sub">
|
||||
{selected.artist}
|
||||
{selected.year ? ` · ${selected.year}` : ""} · {selected.song_count} tracks ·{" "}
|
||||
{fmtDuration(selected.duration)}
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn btn-sm" onClick={() => setSelected(null)}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<table className="data-table">
|
||||
<tbody>
|
||||
{selected.songs.map((s) => (
|
||||
<tr key={s.id}>
|
||||
<td style={{ width: 36 }} className="cell-sub mono">
|
||||
{s.track ?? <IconPlay />}
|
||||
</td>
|
||||
<td className="cell-strong">{s.title}</td>
|
||||
<td style={{ textAlign: "right", width: 60 }} className="cell-sub mono">
|
||||
{fmtDuration(s.duration)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2021",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2021", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"root":["./src/app.tsx","./src/api.ts","./src/main.tsx","./src/components/sidebar.tsx","./src/components/icons.tsx","./src/components/ui.tsx","./src/lib/toast.tsx","./src/pages/dashboard.tsx","./src/pages/settings.tsx","./src/pages/emby/airing.tsx","./src/pages/emby/bulkassign.tsx","./src/pages/emby/collections.tsx","./src/pages/emby/favorites.tsx","./src/pages/emby/generator.tsx","./src/pages/navidrome/collectioncompleteness.tsx","./src/pages/navidrome/covermanager.tsx","./src/pages/navidrome/library.tsx"],"version":"5.9.3"}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
// The FastAPI backend serves the built app from ./dist and owns every /api route.
|
||||
// During `npm run dev` we proxy /api (and Emby/Navidrome image routes) to it.
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: {
|
||||
outDir: "dist",
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": "http://localhost:8500",
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user