fix(admin): correct dashboard library counts and scans (#78)

* fix(admin): correct dashboard library counts and scans

* fix(admin): handle mixed library dashboard counts

* fix(admin): refine dashboard count affordances
This commit is contained in:
zZebrahz
2026-06-07 19:59:10 -04:00
committed by GitHub
parent 6b1554a86c
commit c4e67effc3
3 changed files with 179 additions and 50 deletions
+44 -8
View File
@@ -22,7 +22,9 @@ type AdminStats struct {
TotalFiles int `json:"total_files"`
TotalUsers int `json:"total_users"`
TotalMovies int `json:"total_movies"`
TotalMovieFiles int `json:"total_movie_files"`
TotalShows int `json:"total_shows"`
TotalShowFiles int `json:"total_show_files"`
ActiveStreams int `json:"active_streams"`
TotalStorageBytes int64 `json:"total_storage_bytes"`
WatchProviderActivity WatchProviderActivity `json:"watch_provider_activity"`
@@ -127,13 +129,15 @@ func queryAdminStats(ctx context.Context, pool *pgxpool.Pool) (AdminStats, error
}
var (
totalUsers int64
totalItems int64
totalFiles int64
totalMovies int64
totalShows int64
activeStreams int64
totalStorage int64
totalUsers int64
totalItems int64
totalFiles int64
totalMovies int64
totalMovieFiles int64
totalShows int64
totalShowFiles int64
activeStreams int64
totalStorage int64
)
row := pool.QueryRow(ctx, `
@@ -151,8 +155,34 @@ func queryAdminStats(ctx context.Context, pool *pgxpool.Pool) (AdminStats, error
file_stats AS (
SELECT
COUNT(*)::bigint AS total_files,
COUNT(*) FILTER (WHERE file_kind = 'movie')::bigint AS total_movie_files,
COUNT(*) FILTER (WHERE file_kind = 'series')::bigint AS total_show_files,
COALESCE(SUM(file_size), 0)::bigint AS total_storage_bytes
FROM media_files
FROM (
SELECT
media_files.file_size,
CASE
WHEN lower(trim(COALESCE(NULLIF(media_items.type, ''), ''))) = 'movie'
THEN 'movie'
WHEN lower(trim(COALESCE(NULLIF(media_items.type, ''), ''))) = 'series'
THEN 'series'
WHEN episodes.content_id IS NOT NULL
THEN 'series'
WHEN lower(trim(COALESCE(NULLIF(media_files.base_type, ''), ''))) IN ('movie', 'movies')
THEN 'movie'
WHEN lower(trim(COALESCE(NULLIF(media_files.base_type, ''), ''))) IN ('series', 'tv', 'show', 'shows', 'tvshows')
THEN 'series'
WHEN lower(trim(media_folders.type)) IN ('movie', 'movies')
THEN 'movie'
WHEN lower(trim(media_folders.type)) IN ('series', 'tv', 'show', 'shows', 'tvshows')
THEN 'series'
ELSE ''
END AS file_kind
FROM media_files
JOIN media_folders ON media_folders.id = media_files.media_folder_id
LEFT JOIN media_items ON media_items.content_id = media_files.content_id
LEFT JOIN episodes ON episodes.content_id = media_files.episode_id
) classified_files
),
session_stats AS (
SELECT COUNT(*)::bigint AS active_streams
@@ -163,7 +193,9 @@ func queryAdminStats(ctx context.Context, pool *pgxpool.Pool) (AdminStats, error
item_stats.total_items,
file_stats.total_files,
item_stats.total_movies,
file_stats.total_movie_files,
item_stats.total_shows,
file_stats.total_show_files,
session_stats.active_streams,
file_stats.total_storage_bytes
FROM user_stats
@@ -176,7 +208,9 @@ func queryAdminStats(ctx context.Context, pool *pgxpool.Pool) (AdminStats, error
&totalItems,
&totalFiles,
&totalMovies,
&totalMovieFiles,
&totalShows,
&totalShowFiles,
&activeStreams,
&totalStorage,
); err != nil {
@@ -194,7 +228,9 @@ func queryAdminStats(ctx context.Context, pool *pgxpool.Pool) (AdminStats, error
TotalItems: int(totalItems),
TotalFiles: int(totalFiles),
TotalMovies: int(totalMovies),
TotalMovieFiles: int(totalMovieFiles),
TotalShows: int(totalShows),
TotalShowFiles: int(totalShowFiles),
ActiveStreams: int(activeStreams),
TotalStorageBytes: totalStorage,
WatchProviderActivity: activity,
+2
View File
@@ -2049,7 +2049,9 @@ export interface AdminStats {
total_files: number;
total_users: number;
total_movies: number;
total_movie_files?: number;
total_shows: number;
total_show_files?: number;
active_streams: number;
total_storage_bytes: number;
watch_provider_activity: WatchProviderActivity;
+133 -42
View File
@@ -1,14 +1,17 @@
import type { ReactNode } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link, useNavigate } from "react-router";
import { AdminSessionActions } from "@/components/AdminSessionActions";
import { useEventChannel } from "@/components/realtimeEventsContext";
import { fetchAdminStats, useAdminStats, useAdminSessions } from "@/hooks/queries/admin/stats";
import { useAdminUsers } from "@/hooks/queries/admin/users";
import {
useAdminLibraries,
useCancelLibraryScans,
useScanAllLibraries,
useScanLibrary,
} from "@/hooks/queries/admin/libraries";
import { useActiveScans } from "@/hooks/queries/admin/scans";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -31,6 +34,7 @@ import {
Pause,
Library,
ScanLine,
Square,
} from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
import type {
@@ -38,16 +42,35 @@ import type {
AdminStats,
Library as LibraryType,
AdminUser,
ScanRun,
WatchProviderActivity,
} from "@/api/types";
import { useQueryClient } from "@tanstack/react-query";
import { adminKeys } from "@/hooks/queries/keys";
import { usePageActivity } from "@/hooks/usePageActivity";
import { cn } from "@/lib/utils";
import { compareActiveScans, formatActiveScanMode, formatActiveScanProgress } from "@/lib/scanRuns";
const REFRESH_SPINNER_MIN_VISIBLE_MS = 1_000;
const DASHBOARD_AUTO_REFRESH_MS = 60_000;
const RELATIVE_UPDATED_LABEL_TICK_MS = 30_000;
function formatFileCount(count: number | null | undefined) {
if (count == null) {
return "—";
}
return count === 1 ? "1 file" : `${count.toLocaleString()} files`;
}
function formatDashboardLibraryScanProgress(scan: ScanRun, activeScanCount: number) {
const status = scan.status === "running" ? "Scanning" : "Queued";
const progress = formatActiveScanProgress(scan);
const detail =
progress || (scan.status === "running" ? formatActiveScanMode(scan) : "Waiting for capacity");
const extraScans = activeScanCount > 1 ? ` + ${activeScanCount - 1} more` : "";
return `${status}: ${detail}${extraScans}`;
}
export default function AdminDashboard() {
const queryClient = useQueryClient();
const statsQuery = useAdminStats();
@@ -222,6 +245,7 @@ export default function AdminDashboard() {
<Button
variant="default"
size="sm"
className="cursor-pointer"
onClick={() => {
if (libraries.length > 0) {
scanAll.mutate();
@@ -311,13 +335,13 @@ function StatsRow({
{
label: "Total Movies",
value: stats.total_movies.toLocaleString(),
sub: `of ${stats.total_items.toLocaleString()} items`,
sub: formatFileCount(stats.total_movie_files),
icon: <Film className="h-4 w-4" />,
},
{
label: "Total Shows",
value: stats.total_shows.toLocaleString(),
sub: `${stats.total_files.toLocaleString()} files total`,
sub: formatFileCount(stats.total_show_files),
icon: <Tv className="h-4 w-4" />,
},
{
@@ -329,7 +353,7 @@ function StatsRow({
{
label: "Storage",
value: storageDisplay,
sub: `${stats.total_files.toLocaleString()} files`,
sub: formatFileCount(stats.total_files),
icon: <HardDrive className="h-4 w-4" />,
},
];
@@ -629,7 +653,26 @@ function LibrariesCard({
isLoading: boolean;
error: unknown;
}) {
useEventChannel("scans");
const scanLibrary = useScanLibrary();
const cancelScans = useCancelLibraryScans();
const { data: activeScans = [] } = useActiveScans();
const activeScansByLibraryId = useMemo(() => {
const scansByLibraryID = new Map<number, ScanRun[]>();
for (const scan of activeScans) {
if (scan.status !== "accepted" && scan.status !== "running") {
continue;
}
const scans = scansByLibraryID.get(scan.library_id) ?? [];
scans.push(scan);
scansByLibraryID.set(scan.library_id, scans);
}
for (const scans of scansByLibraryID.values()) {
scans.sort(compareActiveScans);
}
return scansByLibraryID;
}, [activeScans]);
return (
<Card>
@@ -652,47 +695,95 @@ function LibrariesCard({
No libraries configured.
</div>
) : (
libraries.map((lib) => (
<div
key={lib.id}
className="bg-surface border-border hover:bg-surface-hover flex cursor-pointer items-center gap-3 rounded-md border p-3 transition-colors duration-150"
>
{lib.poster_url ? (
<img
src={lib.poster_url}
alt={lib.name}
className="border-border h-8 w-14 flex-shrink-0 rounded border object-cover"
/>
) : (
<div className="bg-primary/5 border-primary/10 flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg border">
<Library className="text-primary h-4 w-4" />
libraries.map((lib) => {
const activeLibraryScans = activeScansByLibraryId.get(lib.id) ?? [];
const primaryActiveScan = activeLibraryScans[0];
const hasActiveScan = activeLibraryScans.length > 0;
const isScanStarting = scanLibrary.isPending && scanLibrary.variables === lib.id;
const isCancellingScan = cancelScans.isPending && cancelScans.variables === lib.id;
const scanProgressLabel = primaryActiveScan
? formatDashboardLibraryScanProgress(primaryActiveScan, activeLibraryScans.length)
: isScanStarting
? "Starting scan..."
: "";
return (
<div
key={lib.id}
className="bg-surface border-border hover:bg-surface-hover flex items-center gap-3 rounded-md border p-3 transition-colors duration-150"
>
{lib.poster_url ? (
<img
src={lib.poster_url}
alt={lib.name}
className="border-border h-8 w-14 flex-shrink-0 rounded border object-cover"
/>
) : (
<div className="bg-primary/5 border-primary/10 flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg border">
<Library className="text-primary h-4 w-4" />
</div>
)}
<div className="min-w-0 flex-1">
<div className="text-sm font-bold">{lib.name}</div>
<div className="text-muted-foreground flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-[11px]">
<span>
{lib.type} · {lib.paths.length} {lib.paths.length === 1 ? "path" : "paths"}
</span>
{scanProgressLabel ? (
<>
<span className="text-border/70">·</span>
<span className="max-w-[22rem] truncate text-amber-300 tabular-nums">
{scanProgressLabel}
</span>
</>
) : null}
</div>
</div>
)}
<div className="min-w-0 flex-1">
<div className="text-sm font-bold">{lib.name}</div>
<div className="text-muted-foreground text-[11px]">
{lib.type} · {lib.paths.length} {lib.paths.length === 1 ? "path" : "paths"}
<div className="flex flex-shrink-0 items-center gap-2">
<Button
variant={hasActiveScan ? "destructive" : "ghost"}
size="icon"
className="h-7 w-7 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
if (hasActiveScan) {
cancelScans.mutate(lib.id);
return;
}
scanLibrary.mutate(lib.id);
}}
disabled={hasActiveScan ? isCancellingScan : isScanStarting}
title={hasActiveScan ? "Stop Library Scans" : "Scan Library"}
aria-label={hasActiveScan ? `Stop scans for ${lib.name}` : `Scan ${lib.name}`}
>
{hasActiveScan ? (
<Square className="h-3 w-3 fill-current" />
) : (
<ScanLine className={cn("h-3 w-3", isScanStarting && "animate-pulse")} />
)}
</Button>
<div
className={cn(
"h-2 w-2 rounded-full",
hasActiveScan || isScanStarting
? "bg-amber-400 shadow-[0_0_6px_rgba(251,191,36,0.65)]"
: lib.enabled
? "bg-green-500"
: "bg-muted-foreground/30",
hasActiveScan && "animate-pulse",
)}
title={
hasActiveScan
? "Scan in progress"
: lib.enabled
? "Library enabled"
: "Library disabled"
}
/>
</div>
</div>
<div className="flex flex-shrink-0 items-center gap-2">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={(e) => {
e.stopPropagation();
scanLibrary.mutate(lib.id);
}}
title="Scan"
>
<ScanLine className="h-3 w-3" />
</Button>
<div
className={`h-2 w-2 rounded-full ${lib.enabled ? "bg-green-500" : "bg-muted-foreground/30"}`}
/>
</div>
</div>
))
);
})
)}
</CardContent>
</Card>