Homelabtoolkit v1
This commit is contained in:
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user