feat: implement cache management system with statistics and clearing functionality
This commit is contained in:
@@ -1 +1 @@
|
|||||||
NEXT_PUBLIC_COMMIT_SHA=52e465d
|
NEXT_PUBLIC_COMMIT_SHA=3c13c13
|
||||||
|
|||||||
@@ -418,7 +418,12 @@ export const AudioPlayer: React.FC = () => {
|
|||||||
{/* Track info */}
|
{/* Track info */}
|
||||||
<div className="flex items-center flex-1 min-w-0">
|
<div className="flex items-center flex-1 min-w-0">
|
||||||
<Image
|
<Image
|
||||||
src={currentTrack.coverArt || '/default-user.jpg'}
|
src={
|
||||||
|
currentTrack.coverArt &&
|
||||||
|
(currentTrack.coverArt.startsWith('http') || currentTrack.coverArt.startsWith('/'))
|
||||||
|
? currentTrack.coverArt
|
||||||
|
: '/default-user.jpg'
|
||||||
|
}
|
||||||
alt={currentTrack.name}
|
alt={currentTrack.name}
|
||||||
width={48}
|
width={48}
|
||||||
height={48}
|
height={48}
|
||||||
|
|||||||
223
app/components/CacheManagement.tsx
Normal file
223
app/components/CacheManagement.tsx
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Database,
|
||||||
|
Trash2,
|
||||||
|
RefreshCw,
|
||||||
|
HardDrive
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { CacheManager } from '@/lib/cache';
|
||||||
|
|
||||||
|
export function CacheManagement() {
|
||||||
|
const [cacheStats, setCacheStats] = useState({
|
||||||
|
total: 0,
|
||||||
|
expired: 0,
|
||||||
|
size: '0 B'
|
||||||
|
});
|
||||||
|
const [isClearing, setIsClearing] = useState(false);
|
||||||
|
const [lastCleared, setLastCleared] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const loadCacheStats = () => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
let total = 0;
|
||||||
|
let expired = 0;
|
||||||
|
let totalSize = 0;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Check localStorage for cache entries
|
||||||
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
|
const key = localStorage.key(i);
|
||||||
|
if (key && (key.startsWith('cache-') || key.startsWith('navidrome-cache-') || key.startsWith('library-cache-'))) {
|
||||||
|
total++;
|
||||||
|
const value = localStorage.getItem(key);
|
||||||
|
if (value) {
|
||||||
|
totalSize += key.length + value.length;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
if (parsed.expiresAt && now > parsed.expiresAt) {
|
||||||
|
expired++;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
expired++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert bytes to human readable format
|
||||||
|
const formatSize = (bytes: number): string => {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
};
|
||||||
|
|
||||||
|
setCacheStats({
|
||||||
|
total,
|
||||||
|
expired,
|
||||||
|
size: formatSize(totalSize * 2) // *2 for UTF-16 encoding
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadCacheStats();
|
||||||
|
|
||||||
|
// Check if there's a last cleared timestamp
|
||||||
|
const lastClearedTime = localStorage.getItem('cache-last-cleared');
|
||||||
|
if (lastClearedTime) {
|
||||||
|
setLastCleared(new Date(parseInt(lastClearedTime)).toLocaleString());
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleClearCache = async () => {
|
||||||
|
setIsClearing(true);
|
||||||
|
try {
|
||||||
|
// Clear all cache using the CacheManager
|
||||||
|
CacheManager.clearAll();
|
||||||
|
|
||||||
|
// Also clear any other cache-related localStorage items
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const keys = Object.keys(localStorage);
|
||||||
|
keys.forEach(key => {
|
||||||
|
if (key.startsWith('cache-') ||
|
||||||
|
key.startsWith('navidrome-cache-') ||
|
||||||
|
key.startsWith('library-cache-') ||
|
||||||
|
key.includes('album') ||
|
||||||
|
key.includes('artist') ||
|
||||||
|
key.includes('song')) {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set last cleared timestamp
|
||||||
|
localStorage.setItem('cache-last-cleared', Date.now().toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update stats
|
||||||
|
loadCacheStats();
|
||||||
|
setLastCleared(new Date().toLocaleString());
|
||||||
|
|
||||||
|
// Show success feedback
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsClearing(false);
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to clear cache:', error);
|
||||||
|
setIsClearing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCleanExpired = () => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const keysToRemove: string[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
|
const key = localStorage.key(i);
|
||||||
|
if (key && (key.startsWith('cache-') || key.startsWith('navidrome-cache-') || key.startsWith('library-cache-'))) {
|
||||||
|
try {
|
||||||
|
const value = localStorage.getItem(key);
|
||||||
|
if (value) {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
if (parsed.expiresAt && now > parsed.expiresAt) {
|
||||||
|
keysToRemove.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Invalid cache item, remove it
|
||||||
|
keysToRemove.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
keysToRemove.forEach(key => localStorage.removeItem(key));
|
||||||
|
loadCacheStats();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="break-inside-avoid">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Database className="h-5 w-5" />
|
||||||
|
Cache Management
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Manage application cache to improve performance and free up storage
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Cache Statistics */}
|
||||||
|
<div className="grid grid-cols-3 gap-4 text-center">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-2xl font-bold">{cacheStats.total}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Total Items</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-2xl font-bold">{cacheStats.expired}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Expired</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-2xl font-bold">{cacheStats.size}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Storage Used</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cache Actions */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={handleClearCache}
|
||||||
|
disabled={isClearing}
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{isClearing ? (
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
|
)}
|
||||||
|
{isClearing ? 'Clearing...' : 'Clear All Cache'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleCleanExpired}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
<HardDrive className="h-4 w-4 mr-2" />
|
||||||
|
Clean Expired
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={loadCacheStats}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
|
Refresh Stats
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cache Info */}
|
||||||
|
<div className="text-sm text-muted-foreground space-y-1">
|
||||||
|
<p>Cache includes albums, artists, songs, and image URLs to improve loading times.</p>
|
||||||
|
{lastCleared && (
|
||||||
|
<p>Last cleared: {lastCleared}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
|
|||||||
const [recommendedSongs, setRecommendedSongs] = useState<Song[]>([]);
|
const [recommendedSongs, setRecommendedSongs] = useState<Song[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [songStates, setSongStates] = useState<Record<string, boolean>>({});
|
const [songStates, setSongStates] = useState<Record<string, boolean>>({});
|
||||||
|
const [imageLoadingStates, setImageLoadingStates] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
// Get greeting based on time of day
|
// Get greeting based on time of day
|
||||||
const hour = new Date().getHours();
|
const hour = new Date().getHours();
|
||||||
@@ -50,12 +51,15 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
|
|||||||
const recommendations = shuffled.slice(0, 6);
|
const recommendations = shuffled.slice(0, 6);
|
||||||
setRecommendedSongs(recommendations);
|
setRecommendedSongs(recommendations);
|
||||||
|
|
||||||
// Initialize starred states
|
// Initialize starred states and image loading states
|
||||||
const states: Record<string, boolean> = {};
|
const states: Record<string, boolean> = {};
|
||||||
|
const imageStates: Record<string, boolean> = {};
|
||||||
recommendations.forEach((song: Song) => {
|
recommendations.forEach((song: Song) => {
|
||||||
states[song.id] = !!song.starred;
|
states[song.id] = !!song.starred;
|
||||||
|
imageStates[song.id] = true; // Start with loading state
|
||||||
});
|
});
|
||||||
setSongStates(states);
|
setSongStates(states);
|
||||||
|
setImageLoadingStates(imageStates);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load song recommendations:', error);
|
console.error('Failed to load song recommendations:', error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -79,7 +83,7 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
|
|||||||
album: song.album || 'Unknown Album',
|
album: song.album || 'Unknown Album',
|
||||||
albumId: song.albumId || '',
|
albumId: song.albumId || '',
|
||||||
duration: song.duration || 0,
|
duration: song.duration || 0,
|
||||||
coverArt: song.coverArt,
|
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
|
||||||
starred: !!song.starred
|
starred: !!song.starred
|
||||||
};
|
};
|
||||||
await playTrack(track, true);
|
await playTrack(track, true);
|
||||||
@@ -154,21 +158,34 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="relative w-12 h-12 rounded overflow-hidden bg-muted flex-shrink-0">
|
<div className="relative w-12 h-12 rounded overflow-hidden bg-muted flex-shrink-0">
|
||||||
{song.coverArt && api ? (
|
{song.coverArt && api ? (
|
||||||
<Image
|
<>
|
||||||
src={api.getCoverArtUrl(song.coverArt, 100)}
|
{imageLoadingStates[song.id] && (
|
||||||
alt={song.title}
|
<div className="absolute inset-0 bg-muted flex items-center justify-center">
|
||||||
fill
|
<Music className="w-6 h-6 text-muted-foreground animate-pulse" />
|
||||||
className="object-cover"
|
</div>
|
||||||
sizes="48px"
|
)}
|
||||||
/>
|
<Image
|
||||||
|
src={api.getCoverArtUrl(song.coverArt, 100)}
|
||||||
|
alt={song.title}
|
||||||
|
fill
|
||||||
|
className={`object-cover transition-opacity duration-300 ${
|
||||||
|
imageLoadingStates[song.id] ? 'opacity-0' : 'opacity-100'
|
||||||
|
}`}
|
||||||
|
sizes="48px"
|
||||||
|
onLoad={() => setImageLoadingStates(prev => ({ ...prev, [song.id]: false }))}
|
||||||
|
onError={() => setImageLoadingStates(prev => ({ ...prev, [song.id]: false }))}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full h-full flex items-center justify-center">
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
<Music className="w-6 h-6 text-muted-foreground" />
|
<Music className="w-6 h-6 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
{!imageLoadingStates[song.id] && (
|
||||||
<Play className="w-4 h-4 text-white" />
|
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||||
</div>
|
<Play className="w-4 h-4 text-white" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
|
|||||||
@@ -1,244 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { usePathname } from 'next/navigation';
|
|
||||||
import { Button } from "../../components/ui/button";
|
|
||||||
import { ScrollArea } from "../../components/ui/scroll-area";
|
|
||||||
import Link from "next/link";
|
|
||||||
import Image from "next/image";
|
|
||||||
import { Playlist, Album } from "@/lib/navidrome";
|
|
||||||
import {
|
|
||||||
Search,
|
|
||||||
Home,
|
|
||||||
List,
|
|
||||||
Radio,
|
|
||||||
Users,
|
|
||||||
Disc,
|
|
||||||
Music,
|
|
||||||
Heart,
|
|
||||||
Grid3X3,
|
|
||||||
Clock,
|
|
||||||
Settings,
|
|
||||||
Circle
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useNavidrome } from "./NavidromeContext";
|
|
||||||
import { useRecentlyPlayedAlbums } from "@/hooks/use-recently-played-albums";
|
|
||||||
import { useSidebarShortcuts } from "@/hooks/use-sidebar-shortcuts";
|
|
||||||
import { useSidebarLayout, SidebarItem } from "@/hooks/use-sidebar-layout";
|
|
||||||
import {
|
|
||||||
ContextMenu,
|
|
||||||
ContextMenuContent,
|
|
||||||
ContextMenuItem,
|
|
||||||
ContextMenuTrigger,
|
|
||||||
} from "@/components/ui/context-menu";
|
|
||||||
|
|
||||||
// Icon mapping for sidebar items
|
|
||||||
const iconMap: Record<string, React.ReactNode> = {
|
|
||||||
search: <Search className="h-4 w-4" />,
|
|
||||||
home: <Home className="h-4 w-4" />,
|
|
||||||
queue: <List className="h-4 w-4" />,
|
|
||||||
radio: <Radio className="h-4 w-4" />,
|
|
||||||
artists: <Users className="h-4 w-4" />,
|
|
||||||
albums: <Disc className="h-4 w-4" />,
|
|
||||||
playlists: <Music className="h-4 w-4" />,
|
|
||||||
favorites: <Heart className="h-4 w-4" />,
|
|
||||||
browse: <Grid3X3 className="h-4 w-4" />,
|
|
||||||
songs: <Circle className="h-4 w-4" />,
|
|
||||||
history: <Clock className="h-4 w-4" />,
|
|
||||||
settings: <Settings className="h-4 w-4" />,
|
|
||||||
};
|
|
||||||
|
|
||||||
interface SidebarProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
||||||
playlists: Playlist[];
|
|
||||||
visible?: boolean;
|
|
||||||
favoriteAlbums?: Array<{id: string, name: string, artist: string, coverArt?: string}>;
|
|
||||||
onRemoveFavoriteAlbum?: (albumId: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Sidebar({ className, playlists, visible = true, favoriteAlbums = [], onRemoveFavoriteAlbum }: SidebarProps) {
|
|
||||||
const pathname = usePathname();
|
|
||||||
const { api } = useNavidrome();
|
|
||||||
const { recentAlbums } = useRecentlyPlayedAlbums();
|
|
||||||
const { shortcutType } = useSidebarShortcuts();
|
|
||||||
const { settings } = useSidebarLayout();
|
|
||||||
|
|
||||||
if (!visible) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if a route is active
|
|
||||||
const isRouteActive = (href: string): boolean => {
|
|
||||||
if (href === '/') return pathname === '/';
|
|
||||||
return pathname.startsWith(href);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get visible navigation items
|
|
||||||
const visibleItems = settings.items.filter(item => item.visible);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn("pb-23 relative w-16", className)}>
|
|
||||||
<div className="space-y-4 py-4 pt-6">
|
|
||||||
<div className="px-3 py-2">
|
|
||||||
<div className="space-y-1">
|
|
||||||
{/* Main Navigation Items */}
|
|
||||||
{visibleItems.map((item) => (
|
|
||||||
<Link key={item.id} href={item.href}>
|
|
||||||
<Button
|
|
||||||
variant={isRouteActive(item.href) ? "secondary" : "ghost"}
|
|
||||||
className="w-full justify-center px-2"
|
|
||||||
title={item.label}
|
|
||||||
>
|
|
||||||
{settings.showIcons && (iconMap[item.icon] || <div className="h-4 w-4" />)}
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Dynamic Shortcuts Section */}
|
|
||||||
{(shortcutType === 'albums' || shortcutType === 'both') && favoriteAlbums.length > 0 && (
|
|
||||||
<>
|
|
||||||
<div className="border-t my-2"></div>
|
|
||||||
{favoriteAlbums.slice(0, 5).map((album) => (
|
|
||||||
<ContextMenu key={album.id}>
|
|
||||||
<ContextMenuTrigger>
|
|
||||||
<Link href={`/album/${album.id}`}>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="w-full justify-center px-2"
|
|
||||||
title={`${album.name} by ${album.artist}`}
|
|
||||||
>
|
|
||||||
{album.coverArt && api ? (
|
|
||||||
<Image
|
|
||||||
src={api.getCoverArtUrl(album.coverArt, 32)}
|
|
||||||
alt={album.name}
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
className="rounded"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
className="h-4 w-4"
|
|
||||||
>
|
|
||||||
<path d="m16 6 4 14" />
|
|
||||||
<path d="M12 6v14" />
|
|
||||||
<path d="M8 8v12" />
|
|
||||||
<path d="M4 4v16" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</ContextMenuTrigger>
|
|
||||||
<ContextMenuContent>
|
|
||||||
<ContextMenuItem
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
onRemoveFavoriteAlbum?.(album.id);
|
|
||||||
}}
|
|
||||||
className="text-destructive focus:text-destructive"
|
|
||||||
>
|
|
||||||
Remove from favorites
|
|
||||||
</ContextMenuItem>
|
|
||||||
</ContextMenuContent>
|
|
||||||
</ContextMenu>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Recently Played Albums */}
|
|
||||||
{(shortcutType === 'albums' || shortcutType === 'both') && recentAlbums.length > 0 && (
|
|
||||||
<>
|
|
||||||
<div className="border-t my-2"></div>
|
|
||||||
{recentAlbums.slice(0, 5).map((album) => (
|
|
||||||
<Link key={album.id} href={`/album/${album.id}`}>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="w-full justify-center px-2"
|
|
||||||
title={`${album.name} by ${album.artist} (Recently Played)`}
|
|
||||||
>
|
|
||||||
{album.coverArt && api ? (
|
|
||||||
<Image
|
|
||||||
src={api.getCoverArtUrl(album.coverArt, 32)}
|
|
||||||
alt={album.name}
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
className="rounded opacity-70"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
className="h-4 w-4 opacity-70"
|
|
||||||
>
|
|
||||||
<path d="m16 6 4 14" />
|
|
||||||
<path d="M12 6v14" />
|
|
||||||
<path d="M8 8v12" />
|
|
||||||
<path d="M4 4v16" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Playlists Section */}
|
|
||||||
{(shortcutType === 'playlists' || shortcutType === 'both') && playlists.length > 0 && (
|
|
||||||
<>
|
|
||||||
<div className="border-t my-2"></div>
|
|
||||||
{playlists.slice(0, 5).map((playlist) => (
|
|
||||||
<Link key={playlist.id} href={`/playlist/${playlist.id}`}>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="w-full justify-center px-2"
|
|
||||||
title={`${playlist.name} by ${playlist.owner} - ${playlist.songCount} songs`}
|
|
||||||
>
|
|
||||||
{playlist.coverArt && api ? (
|
|
||||||
<Image
|
|
||||||
src={api.getCoverArtUrl(playlist.coverArt, 32)}
|
|
||||||
alt={playlist.name}
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
className="rounded"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
className="h-4 w-4"
|
|
||||||
>
|
|
||||||
<path d="M21 15V6" />
|
|
||||||
<path d="M18.5 18a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z" />
|
|
||||||
<path d="M12 12H3" />
|
|
||||||
<path d="M16 6H3" />
|
|
||||||
<path d="M12 18H3" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -13,6 +13,7 @@ import { useStandaloneLastFm } from '@/hooks/use-standalone-lastfm';
|
|||||||
import { useSidebarShortcuts, SidebarShortcutType } from '@/hooks/use-sidebar-shortcuts';
|
import { useSidebarShortcuts, SidebarShortcutType } from '@/hooks/use-sidebar-shortcuts';
|
||||||
import { SidebarCustomization } from '@/app/components/SidebarCustomization';
|
import { SidebarCustomization } from '@/app/components/SidebarCustomization';
|
||||||
import { SettingsManagement } from '@/app/components/SettingsManagement';
|
import { SettingsManagement } from '@/app/components/SettingsManagement';
|
||||||
|
import { CacheManagement } from '@/app/components/CacheManagement';
|
||||||
import { FaServer, FaUser, FaLock, FaCheck, FaTimes, FaLastfm, FaCog } from 'react-icons/fa';
|
import { FaServer, FaUser, FaLock, FaCheck, FaTimes, FaLastfm, FaCog } from 'react-icons/fa';
|
||||||
import { Settings, ExternalLink } from 'lucide-react';
|
import { Settings, ExternalLink } from 'lucide-react';
|
||||||
|
|
||||||
@@ -706,6 +707,11 @@ const SettingsPage = () => {
|
|||||||
<SettingsManagement />
|
<SettingsManagement />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Cache Management */}
|
||||||
|
<div className="break-inside-avoid mb-6">
|
||||||
|
<CacheManagement />
|
||||||
|
</div>
|
||||||
|
|
||||||
<Card className="mb-6 break-inside-avoid">
|
<Card className="mb-6 break-inside-avoid">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Appearance</CardTitle>
|
<CardTitle>Appearance</CardTitle>
|
||||||
|
|||||||
0
hooks/use-cached-image.ts
Normal file
0
hooks/use-cached-image.ts
Normal file
110
hooks/use-library-cache.ts
Normal file
110
hooks/use-library-cache.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface LibraryCacheItem<T> {
|
||||||
|
data: T;
|
||||||
|
timestamp: number;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLibraryCache<T>(
|
||||||
|
key: string,
|
||||||
|
fetcher: () => Promise<T>,
|
||||||
|
ttl: number = 30 * 60 * 1000 // 30 minutes default
|
||||||
|
) {
|
||||||
|
const [data, setData] = useState<T | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
|
const getCacheKey = (key: string) => `library-cache-${key}`;
|
||||||
|
|
||||||
|
const getFromCache = (key: string): T | null => {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cached = localStorage.getItem(getCacheKey(key));
|
||||||
|
if (!cached) return null;
|
||||||
|
|
||||||
|
const item: LibraryCacheItem<T> = JSON.parse(cached);
|
||||||
|
|
||||||
|
// Check if expired
|
||||||
|
if (Date.now() > item.expiresAt) {
|
||||||
|
localStorage.removeItem(getCacheKey(key));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return item.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to get cached data:', error);
|
||||||
|
localStorage.removeItem(getCacheKey(key));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setToCache = (key: string, data: T, ttl: number) => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
const item: LibraryCacheItem<T> = {
|
||||||
|
data,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
expiresAt: Date.now() + ttl
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.setItem(getCacheKey(key), JSON.stringify(item));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to cache data:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadData = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
const cached = getFromCache(key);
|
||||||
|
if (cached) {
|
||||||
|
setData(cached);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch fresh data
|
||||||
|
try {
|
||||||
|
const result = await fetcher();
|
||||||
|
setData(result);
|
||||||
|
setToCache(key, result, ttl);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err as Error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadData();
|
||||||
|
}, [key, ttl]);
|
||||||
|
|
||||||
|
const refresh = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await fetcher();
|
||||||
|
setData(result);
|
||||||
|
setToCache(key, result, ttl);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err as Error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearCache = () => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
localStorage.removeItem(getCacheKey(key));
|
||||||
|
};
|
||||||
|
|
||||||
|
return { data, loading, error, refresh, clearCache };
|
||||||
|
}
|
||||||
258
lib/cache.ts
Normal file
258
lib/cache.ts
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
// Types for caching (simplified versions to avoid circular imports)
|
||||||
|
interface Album {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
artist: string;
|
||||||
|
artistId: string;
|
||||||
|
coverArt?: string;
|
||||||
|
songCount: number;
|
||||||
|
duration: number;
|
||||||
|
playCount?: number;
|
||||||
|
created: string;
|
||||||
|
starred?: string;
|
||||||
|
year?: number;
|
||||||
|
genre?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Artist {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
albumCount: number;
|
||||||
|
starred?: string;
|
||||||
|
coverArt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Song {
|
||||||
|
id: string;
|
||||||
|
parent: string;
|
||||||
|
isDir: boolean;
|
||||||
|
title: string;
|
||||||
|
artist?: string;
|
||||||
|
artistId?: string;
|
||||||
|
album?: string;
|
||||||
|
albumId?: string;
|
||||||
|
year?: number;
|
||||||
|
genre?: string;
|
||||||
|
coverArt?: string;
|
||||||
|
size?: number;
|
||||||
|
contentType?: string;
|
||||||
|
suffix?: string;
|
||||||
|
starred?: string;
|
||||||
|
duration?: number;
|
||||||
|
bitRate?: number;
|
||||||
|
path?: string;
|
||||||
|
playCount?: number;
|
||||||
|
created: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CacheItem<T> {
|
||||||
|
data: T;
|
||||||
|
timestamp: number;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CacheConfig {
|
||||||
|
defaultTTL: number; // Time to live in milliseconds
|
||||||
|
maxSize: number; // Maximum number of items in cache
|
||||||
|
}
|
||||||
|
|
||||||
|
class Cache<T> {
|
||||||
|
private cache = new Map<string, CacheItem<T>>();
|
||||||
|
private config: CacheConfig;
|
||||||
|
|
||||||
|
constructor(config: CacheConfig = { defaultTTL: 24 * 60 * 60 * 1000, maxSize: 1000 }) {
|
||||||
|
this.config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
set(key: string, data: T, ttl?: number): void {
|
||||||
|
const now = Date.now();
|
||||||
|
const expiresAt = now + (ttl || this.config.defaultTTL);
|
||||||
|
|
||||||
|
// Remove expired items before adding new one
|
||||||
|
this.cleanup();
|
||||||
|
|
||||||
|
// If cache is at max size, remove oldest item
|
||||||
|
if (this.cache.size >= this.config.maxSize) {
|
||||||
|
const oldestKey = this.cache.keys().next().value;
|
||||||
|
if (oldestKey) {
|
||||||
|
this.cache.delete(oldestKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cache.set(key, {
|
||||||
|
data,
|
||||||
|
timestamp: now,
|
||||||
|
expiresAt
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get(key: string): T | null {
|
||||||
|
const item = this.cache.get(key);
|
||||||
|
if (!item) return null;
|
||||||
|
|
||||||
|
// Check if item has expired
|
||||||
|
if (Date.now() > item.expiresAt) {
|
||||||
|
this.cache.delete(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return item.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
has(key: string): boolean {
|
||||||
|
return this.get(key) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(key: string): boolean {
|
||||||
|
return this.cache.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.cache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
size(): number {
|
||||||
|
this.cleanup();
|
||||||
|
return this.cache.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
keys(): string[] {
|
||||||
|
this.cleanup();
|
||||||
|
return Array.from(this.cache.keys());
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanup(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [key, item] of this.cache.entries()) {
|
||||||
|
if (now > item.expiresAt) {
|
||||||
|
this.cache.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get cache statistics
|
||||||
|
getStats() {
|
||||||
|
this.cleanup();
|
||||||
|
const items = Array.from(this.cache.values());
|
||||||
|
const totalSize = items.length;
|
||||||
|
const oldestItem = items.reduce((oldest, item) =>
|
||||||
|
!oldest || item.timestamp < oldest.timestamp ? item : oldest, null as CacheItem<T> | null);
|
||||||
|
const newestItem = items.reduce((newest, item) =>
|
||||||
|
!newest || item.timestamp > newest.timestamp ? item : newest, null as CacheItem<T> | null);
|
||||||
|
|
||||||
|
return {
|
||||||
|
size: totalSize,
|
||||||
|
maxSize: this.config.maxSize,
|
||||||
|
oldestTimestamp: oldestItem?.timestamp,
|
||||||
|
newestTimestamp: newestItem?.timestamp,
|
||||||
|
defaultTTL: this.config.defaultTTL
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Specific cache instances
|
||||||
|
export const albumCache = new Cache<Album[]>({ defaultTTL: 24 * 60 * 60 * 1000, maxSize: 500 }); // 24 hours
|
||||||
|
export const artistCache = new Cache<Artist[]>({ defaultTTL: 24 * 60 * 60 * 1000, maxSize: 200 }); // 24 hours
|
||||||
|
export const songCache = new Cache<Song[]>({ defaultTTL: 12 * 60 * 60 * 1000, maxSize: 1000 }); // 12 hours
|
||||||
|
export const imageCache = new Cache<string>({ defaultTTL: 7 * 24 * 60 * 60 * 1000, maxSize: 1000 }); // 7 days for image URLs
|
||||||
|
|
||||||
|
// Cache management utilities
|
||||||
|
export const CacheManager = {
|
||||||
|
clearAll() {
|
||||||
|
albumCache.clear();
|
||||||
|
artistCache.clear();
|
||||||
|
songCache.clear();
|
||||||
|
imageCache.clear();
|
||||||
|
|
||||||
|
// Also clear localStorage cache data
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const keys = Object.keys(localStorage);
|
||||||
|
keys.forEach(key => {
|
||||||
|
if (key.startsWith('cache-') || key.startsWith('library-cache-')) {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getStats() {
|
||||||
|
return {
|
||||||
|
albums: albumCache.getStats(),
|
||||||
|
artists: artistCache.getStats(),
|
||||||
|
songs: songCache.getStats(),
|
||||||
|
images: imageCache.getStats()
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
getCacheSizeBytes() {
|
||||||
|
if (typeof window === 'undefined') return 0;
|
||||||
|
|
||||||
|
let size = 0;
|
||||||
|
const keys = Object.keys(localStorage);
|
||||||
|
keys.forEach(key => {
|
||||||
|
if (key.startsWith('cache-') || key.startsWith('library-cache-')) {
|
||||||
|
size += localStorage.getItem(key)?.length || 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return size;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Persistent cache for localStorage
|
||||||
|
export const PersistentCache = {
|
||||||
|
set<T>(key: string, data: T, ttl: number = 24 * 60 * 60 * 1000): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
const item: CacheItem<T> = {
|
||||||
|
data,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
expiresAt: Date.now() + ttl
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.setItem(`cache-${key}`, JSON.stringify(item));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to store in localStorage cache:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
get<T>(key: string): T | null {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(`cache-${key}`);
|
||||||
|
if (!stored) return null;
|
||||||
|
|
||||||
|
const item: CacheItem<T> = JSON.parse(stored);
|
||||||
|
|
||||||
|
// Check if expired
|
||||||
|
if (Date.now() > item.expiresAt) {
|
||||||
|
localStorage.removeItem(`cache-${key}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return item.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to read from localStorage cache:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
delete(key: string): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
localStorage.removeItem(`cache-${key}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
const keys = Object.keys(localStorage);
|
||||||
|
keys.forEach(key => {
|
||||||
|
if (key.startsWith('cache-')) {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
|
import { albumCache, artistCache, songCache, imageCache, PersistentCache } from './cache';
|
||||||
|
|
||||||
export interface NavidromeConfig {
|
export interface NavidromeConfig {
|
||||||
serverUrl: string;
|
serverUrl: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user