feat: implement cache management system with statistics and clearing functionality

This commit is contained in:
2025-07-10 20:51:22 +00:00
committed by GitHub
parent 3c13c13143
commit 20317afa74
10 changed files with 634 additions and 258 deletions

View File

@@ -1 +1 @@
NEXT_PUBLIC_COMMIT_SHA=52e465d
NEXT_PUBLIC_COMMIT_SHA=3c13c13

View File

@@ -418,7 +418,12 @@ export const AudioPlayer: React.FC = () => {
{/* Track info */}
<div className="flex items-center flex-1 min-w-0">
<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}
width={48}
height={48}

View 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>
);
}

View File

@@ -20,6 +20,7 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
const [recommendedSongs, setRecommendedSongs] = useState<Song[]>([]);
const [loading, setLoading] = useState(true);
const [songStates, setSongStates] = useState<Record<string, boolean>>({});
const [imageLoadingStates, setImageLoadingStates] = useState<Record<string, boolean>>({});
// Get greeting based on time of day
const hour = new Date().getHours();
@@ -50,12 +51,15 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
const recommendations = shuffled.slice(0, 6);
setRecommendedSongs(recommendations);
// Initialize starred states
// Initialize starred states and image loading states
const states: Record<string, boolean> = {};
const imageStates: Record<string, boolean> = {};
recommendations.forEach((song: Song) => {
states[song.id] = !!song.starred;
imageStates[song.id] = true; // Start with loading state
});
setSongStates(states);
setImageLoadingStates(imageStates);
} catch (error) {
console.error('Failed to load song recommendations:', error);
} finally {
@@ -79,7 +83,7 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
album: song.album || 'Unknown Album',
albumId: song.albumId || '',
duration: song.duration || 0,
coverArt: song.coverArt,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
starred: !!song.starred
};
await playTrack(track, true);
@@ -154,21 +158,34 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
<div className="flex items-center gap-3">
<div className="relative w-12 h-12 rounded overflow-hidden bg-muted flex-shrink-0">
{song.coverArt && api ? (
<Image
src={api.getCoverArtUrl(song.coverArt, 100)}
alt={song.title}
fill
className="object-cover"
sizes="48px"
/>
<>
{imageLoadingStates[song.id] && (
<div className="absolute inset-0 bg-muted flex items-center justify-center">
<Music className="w-6 h-6 text-muted-foreground animate-pulse" />
</div>
)}
<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">
<Music className="w-6 h-6 text-muted-foreground" />
</div>
)}
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<Play className="w-4 h-4 text-white" />
</div>
{!imageLoadingStates[song.id] && (
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<Play className="w-4 h-4 text-white" />
</div>
)}
</div>
<div className="flex-1 min-w-0">

View File

@@ -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>
);
}

View File

@@ -13,6 +13,7 @@ import { useStandaloneLastFm } from '@/hooks/use-standalone-lastfm';
import { useSidebarShortcuts, SidebarShortcutType } from '@/hooks/use-sidebar-shortcuts';
import { SidebarCustomization } from '@/app/components/SidebarCustomization';
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 { Settings, ExternalLink } from 'lucide-react';
@@ -706,6 +707,11 @@ const SettingsPage = () => {
<SettingsManagement />
</div>
{/* Cache Management */}
<div className="break-inside-avoid mb-6">
<CacheManagement />
</div>
<Card className="mb-6 break-inside-avoid">
<CardHeader>
<CardTitle>Appearance</CardTitle>

View File

110
hooks/use-library-cache.ts Normal file
View 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
View 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);
}
});
}
};

View File

@@ -1,4 +1,5 @@
import crypto from 'crypto';
import { albumCache, artistCache, songCache, imageCache, PersistentCache } from './cache';
export interface NavidromeConfig {
serverUrl: string;