chore: remove PostHog analytics and update dependencies to latest minor versions

This commit is contained in:
2026-01-25 00:12:04 +00:00
committed by GitHub
parent 98b085d8c4
commit 6b7f0d8db2
21 changed files with 2021 additions and 3030 deletions

View File

@@ -11,10 +11,6 @@ PORT=3000
# NAVIDROME_USERNAME=your_username # NAVIDROME_USERNAME=your_username
# NAVIDROME_PASSWORD=your_password # NAVIDROME_PASSWORD=your_password
# PostHog Analytics (optional)
POSTHOG_KEY=
POSTHOG_HOST=
# Example for external Navidrome server: # Example for external Navidrome server:
# NAVIDROME_URL=https://your-navidrome-server.com # NAVIDROME_URL=https://your-navidrome-server.com
# NAVIDROME_USERNAME=your_username # NAVIDROME_USERNAME=your_username

View File

@@ -3,15 +3,9 @@ NEXT_PUBLIC_NAVIDROME_URL=http://localhost:4533
NEXT_PUBLIC_NAVIDROME_USERNAME=your_username NEXT_PUBLIC_NAVIDROME_USERNAME=your_username
NEXT_PUBLIC_NAVIDROME_PASSWORD=your_password NEXT_PUBLIC_NAVIDROME_PASSWORD=your_password
# PostHog Analytics (optional)
NEXT_PUBLIC_POSTHOG_KEY=your_posthog_key
NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
# For Docker deployment, use these variable names in your .env file: # For Docker deployment, use these variable names in your .env file:
# NAVIDROME_URL=https://your-navidrome-server.com # NAVIDROME_URL=https://your-navidrome-server.com
# NAVIDROME_USERNAME=your_username # NAVIDROME_USERNAME=your_username
# NAVIDROME_PASSWORD=your_password # NAVIDROME_PASSWORD=your_password
# POSTHOG_KEY=your_posthog_key
# POSTHOG_HOST=https://us.i.posthog.com
# HOST_PORT=3000 # HOST_PORT=3000
# PORT=3000 # PORT=3000

View File

@@ -65,8 +65,6 @@ When running with Docker, use these variable names (without the `NEXT_PUBLIC_` p
- `NAVIDROME_PASSWORD`: Navidrome password (optional - app will prompt if not set) - `NAVIDROME_PASSWORD`: Navidrome password (optional - app will prompt if not set)
- `PORT`: Port for the application to listen on (default: `3000`) - `PORT`: Port for the application to listen on (default: `3000`)
- `HOST_PORT`: Host port to map to container port (docker-compose only, default: `3000`) - `HOST_PORT`: Host port to map to container port (docker-compose only, default: `3000`)
- `POSTHOG_KEY`: PostHog analytics key (optional)
- `POSTHOG_HOST`: PostHog analytics host (optional)
### Development Environment Variables ### Development Environment Variables
@@ -75,8 +73,6 @@ For local development (non-Docker), use these variable names:
- `NEXT_PUBLIC_NAVIDROME_URL`: URL of your Navidrome server - `NEXT_PUBLIC_NAVIDROME_URL`: URL of your Navidrome server
- `NEXT_PUBLIC_NAVIDROME_USERNAME`: Navidrome username - `NEXT_PUBLIC_NAVIDROME_USERNAME`: Navidrome username
- `NEXT_PUBLIC_NAVIDROME_PASSWORD`: Navidrome password - `NEXT_PUBLIC_NAVIDROME_PASSWORD`: Navidrome password
- `NEXT_PUBLIC_POSTHOG_KEY`: PostHog analytics key (optional)
- `NEXT_PUBLIC_POSTHOG_HOST`: PostHog analytics host (optional)
**Note**: Docker deployment uses a runtime replacement mechanism to inject environment variables, while development uses Next.js's built-in `NEXT_PUBLIC_` variables. **Note**: Docker deployment uses a runtime replacement mechanism to inject environment variables, while development uses Next.js's built-in `NEXT_PUBLIC_` variables.

View File

@@ -24,8 +24,6 @@ COPY README.md /app/
ENV NEXT_PUBLIC_NAVIDROME_URL=NEXT_PUBLIC_NAVIDROME_URL ENV NEXT_PUBLIC_NAVIDROME_URL=NEXT_PUBLIC_NAVIDROME_URL
ENV NEXT_PUBLIC_NAVIDROME_USERNAME=NEXT_PUBLIC_NAVIDROME_USERNAME ENV NEXT_PUBLIC_NAVIDROME_USERNAME=NEXT_PUBLIC_NAVIDROME_USERNAME
ENV NEXT_PUBLIC_NAVIDROME_PASSWORD=NEXT_PUBLIC_NAVIDROME_PASSWORD ENV NEXT_PUBLIC_NAVIDROME_PASSWORD=NEXT_PUBLIC_NAVIDROME_PASSWORD
ENV NEXT_PUBLIC_POSTHOG_KEY=NEXT_PUBLIC_POSTHOG_KEY
ENV NEXT_PUBLIC_POSTHOG_HOST=NEXT_PUBLIC_POSTHOG_HOST
ENV PORT=3000 ENV PORT=3000
# Generate git commit hash for build info (fallback if not available) # Generate git commit hash for build info (fallback if not available)

View File

@@ -19,7 +19,6 @@ This is a "Modern" Navidrome (or Subsonic) client built with [Next.js](https://n
- **Audio Player** with queue management - **Audio Player** with queue management
- **Scrobbling** - Track your listening history - **Scrobbling** - Track your listening history
- **Playlist Management** - Create and manage playlists - **Playlist Management** - Create and manage playlists
- **Caching** - Cache/Offline save your server
### Preview ### Preview
![preview](https://github.com/sillyangel/mice/blob/main/public/home-preview.png?raw=true) ![preview](https://github.com/sillyangel/mice/blob/main/public/home-preview.png?raw=true)

View File

@@ -1,560 +0,0 @@
'use client';
import React, { useState, useEffect, useCallback } 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 { Progress } from '@/components/ui/progress';
import { Separator } from '@/components/ui/separator';
import { Switch } from '@/components/ui/switch';
import {
Database,
Trash2,
RefreshCw,
HardDrive,
Download,
Wifi,
WifiOff,
X,
Music,
Globe,
Settings
} from 'lucide-react';
import { CacheManager } from '@/lib/cache';
import { useOfflineDownloads, OfflineItem } from '@/hooks/use-offline-downloads';
import { useAudioPlayer, Track } from '@/app/components/AudioPlayerContext';
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 [offlineItems, setOfflineItems] = useState<OfflineItem[]>([]);
const [offlineMode, setOfflineMode] = useState(false);
const [autoDownloadQueue, setAutoDownloadQueue] = useState(false);
const [isDownloadingQueue, setIsDownloadingQueue] = useState(false);
const {
isSupported: isOfflineSupported,
isInitialized: isOfflineInitialized,
downloadProgress,
offlineStats,
downloadQueue,
enableOfflineMode,
deleteOfflineContent,
getOfflineItems,
clearDownloadProgress
} = useOfflineDownloads();
const { queue } = useAudioPlayer();
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
});
};
const loadOfflineItems = useCallback(async () => {
if (isOfflineInitialized) {
const items = await getOfflineItems();
setOfflineItems(items);
}
}, [isOfflineInitialized, getOfflineItems]);
useEffect(() => {
loadCacheStats();
loadOfflineItems();
// Load offline mode settings
const storedOfflineMode = localStorage.getItem('offline-mode-enabled');
const storedAutoDownload = localStorage.getItem('auto-download-queue');
if (storedOfflineMode) {
setOfflineMode(JSON.parse(storedOfflineMode));
}
if (storedAutoDownload) {
setAutoDownloadQueue(JSON.parse(storedAutoDownload));
}
// Check if there's a last cleared timestamp
const lastClearedTime = localStorage.getItem('cache-last-cleared');
if (lastClearedTime) {
setLastCleared(new Date(parseInt(lastClearedTime)).toLocaleString());
}
}, [loadOfflineItems]);
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();
};
const handleDeleteOfflineItem = async (item: OfflineItem) => {
try {
await deleteOfflineContent(item.id, item.type);
loadOfflineItems();
loadCacheStats();
} catch (error) {
console.error('Failed to delete offline item:', error);
}
};
// Convert Track to Song format for offline downloads
const convertTrackToSong = (track: Track) => ({
id: track.id,
parent: track.albumId || '',
isDir: false,
title: track.name,
album: track.album,
artist: track.artist,
size: 0, // Will be filled when downloaded
contentType: 'audio/mpeg',
suffix: 'mp3',
duration: track.duration,
path: '',
created: new Date().toISOString(),
albumId: track.albumId,
artistId: track.artistId,
type: 'music'
});
const handleOfflineModeToggle = async (enabled: boolean) => {
setOfflineMode(enabled);
localStorage.setItem('offline-mode-enabled', JSON.stringify(enabled));
if (enabled && isOfflineSupported) {
try {
const convertedQueue = queue.map(convertTrackToSong);
await enableOfflineMode({
forceOffline: enabled,
autoDownloadQueue,
currentQueue: convertedQueue
});
} catch (error) {
console.error('Failed to enable offline mode:', error);
}
}
};
const handleAutoDownloadToggle = async (enabled: boolean) => {
setAutoDownloadQueue(enabled);
localStorage.setItem('auto-download-queue', JSON.stringify(enabled));
if (enabled && isOfflineSupported) {
const convertedQueue = queue.map(convertTrackToSong);
await enableOfflineMode({
forceOffline: offlineMode,
autoDownloadQueue: enabled,
currentQueue: convertedQueue
});
}
};
const handleDownloadCurrentQueue = async () => {
if (!queue.length || !isOfflineSupported) return;
setIsDownloadingQueue(true);
try {
const convertedQueue = queue.map(convertTrackToSong);
await downloadQueue(convertedQueue);
loadOfflineItems();
} catch (error) {
console.error('Failed to download queue:', error);
} finally {
setIsDownloadingQueue(false);
}
};
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];
};
return (
<Card className="break-inside-avoid py-5">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Database className="h-5 w-5" />
Cache & Offline Downloads
</CardTitle>
<CardDescription>
Manage application cache and offline content for better performance
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Regular Cache Statistics */}
<div>
<h4 className="text-sm font-medium mb-3">Application Cache</h4>
<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 mt-4">
<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>
</div>
<Separator />
{/* Offline Downloads Section */}
<div>
<h4 className="text-sm font-medium mb-3 flex items-center gap-2">
{isOfflineSupported ? (
<Wifi className="h-4 w-4 text-green-600" />
) : (
<WifiOff className="h-4 w-4 text-red-600" />
)}
Offline Downloads
{!isOfflineSupported && (
<span className="text-xs text-muted-foreground">(Limited Support)</span>
)}
</h4>
{isOfflineSupported && (
<div className="grid grid-cols-3 gap-4 text-center mb-4">
<div className="space-y-1">
<p className="text-lg font-bold">{offlineStats.downloadedAlbums}</p>
<p className="text-xs text-muted-foreground">Albums</p>
</div>
<div className="space-y-1">
<p className="text-lg font-bold">{offlineStats.downloadedSongs}</p>
<p className="text-xs text-muted-foreground">Songs</p>
</div>
<div className="space-y-1">
<p className="text-lg font-bold">{formatSize(offlineStats.totalSize)}</p>
<p className="text-xs text-muted-foreground">Total Size</p>
</div>
</div>
)}
{/* Offline Mode Controls */}
{isOfflineSupported && (
<div className="space-y-4 mb-4 p-3 bg-muted/50 rounded-lg">
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label htmlFor="offline-mode" className="text-sm font-medium">
Offline Mode
</Label>
<p className="text-xs text-muted-foreground">
Force app to use only cached content (good for slow connections)
</p>
</div>
<Switch
id="offline-mode"
checked={offlineMode}
onCheckedChange={handleOfflineModeToggle}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label htmlFor="auto-download" className="text-sm font-medium">
Auto-download Queue
</Label>
<p className="text-xs text-muted-foreground">
Automatically download songs when added to queue
</p>
</div>
<Switch
id="auto-download"
checked={autoDownloadQueue}
onCheckedChange={handleAutoDownloadToggle}
/>
</div>
{/* Queue Download Controls */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Current Queue</span>
<span className="text-xs text-muted-foreground">
{queue.length} song{queue.length !== 1 ? 's' : ''}
</span>
</div>
{queue.length > 0 && (
<Button
onClick={handleDownloadCurrentQueue}
disabled={isDownloadingQueue || downloadProgress.status === 'downloading'}
size="sm"
className="w-full"
variant="outline"
>
{isDownloadingQueue ? (
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
) : (
<Music className="h-4 w-4 mr-2" />
)}
{isDownloadingQueue ? 'Downloading...' : 'Download Current Queue'}
</Button>
)}
{queue.length === 0 && (
<p className="text-xs text-muted-foreground text-center py-2">
Add songs to queue to enable downloading
</p>
)}
</div>
</div>
)}
{/* Download Progress */}
{downloadProgress.status !== 'idle' && (
<div className="space-y-2 mb-4 p-3 bg-muted rounded-lg">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">
{downloadProgress.status === 'downloading' && 'Downloading...'}
{downloadProgress.status === 'starting' && 'Starting download...'}
{downloadProgress.status === 'complete' && 'Download complete!'}
{downloadProgress.status === 'error' && 'Download failed'}
</span>
<Button
variant="ghost"
size="sm"
onClick={clearDownloadProgress}
className="h-6 w-6 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
{downloadProgress.total > 0 && (
<div className="space-y-1">
<Progress
value={(downloadProgress.completed / downloadProgress.total) * 100}
className="h-2"
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>
{downloadProgress.completed} / {downloadProgress.total} songs
{downloadProgress.failed > 0 && ` (${downloadProgress.failed} failed)`}
</span>
<span>{Math.round((downloadProgress.completed / downloadProgress.total) * 100)}%</span>
</div>
</div>
)}
{downloadProgress.currentSong && (
<p className="text-xs text-muted-foreground truncate">
Current: {downloadProgress.currentSong}
</p>
)}
{downloadProgress.error && (
<p className="text-xs text-red-600">
Error: {downloadProgress.error}
</p>
)}
</div>
)}
{/* Offline Items List */}
{offlineItems.length > 0 && (
<div className="space-y-2">
<Label className="text-xs font-medium">Downloaded Content</Label>
<div className="max-h-40 overflow-y-auto space-y-1">
{offlineItems.map((item) => (
<div
key={`${item.type}-${item.id}`}
className="flex items-center justify-between p-2 bg-muted rounded text-sm"
>
<div className="flex items-center gap-2 min-w-0 flex-1">
<Download className="h-3 w-3 text-green-600 flex-shrink-0" />
<div className="min-w-0 flex-1">
<p className="font-medium truncate">{item.name}</p>
<p className="text-xs text-muted-foreground truncate">
{item.artist} {item.type}
</p>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteOfflineItem(item)}
className="h-6 w-6 p-0 flex-shrink-0"
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
</div>
)}
{offlineItems.length === 0 && (
<div className="text-center py-8 text-muted-foreground">
<Download className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">No offline content downloaded</p>
<p className="text-xs">Visit an album page to download content for offline listening</p>
</div>
)}
</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>
{isOfflineSupported && (
<p>Offline downloads use Service Workers for true offline audio playback.</p>
)}
{!isOfflineSupported && (
<p>Limited offline support - only metadata cached without Service Worker support.</p>
)}
{lastCleared && (
<p>Last cleared: {lastCleared}</p>
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -1,66 +0,0 @@
"use client"
import posthog from "posthog-js"
import { PostHogProvider as PHProvider, usePostHog } from "posthog-js/react"
import { Suspense, useEffect } from "react"
import { usePathname, useSearchParams } from "next/navigation"
function PathnameTracker() {
const posthogClient = usePostHog()
const pathname = usePathname()
const searchParams = useSearchParams()
useEffect(() => {
// Only track if PostHog client is available and properly initialized
if (posthogClient && typeof posthogClient.capture === 'function') {
posthogClient.capture('$pageview', {
path: pathname + (searchParams.toString() ? `?${searchParams.toString()}` : ''),
})
}
}, [posthogClient, pathname, searchParams])
return null
}
function SuspendedPostHogPageView() {
return (
<Suspense fallback={null}>
<PathnameTracker />
</Suspense>
)
}
export function PostHogProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
const posthogKey = process.env.NEXT_PUBLIC_POSTHOG_KEY;
// Only initialize PostHog if we have a valid key
if (posthogKey && posthogKey.trim() !== '') {
posthog.init(posthogKey, {
api_host: "/ingest",
ui_host: "https://us.posthog.com",
capture_pageview: 'history_change',
capture_pageleave: true,
capture_exceptions: true,
debug: process.env.NODE_ENV === "development",
});
} else {
console.log('PostHog not initialized - NEXT_PUBLIC_POSTHOG_KEY not provided');
}
}, [])
// Only provide PostHog context if we have a key
const posthogKey = process.env.NEXT_PUBLIC_POSTHOG_KEY;
if (posthogKey && posthogKey.trim() !== '') {
return (
<PHProvider client={posthog}>
<SuspendedPostHogPageView />
{children}
</PHProvider>
);
}
// Return children without PostHog context if no key is provided
return <>{children}</>;
}

View File

@@ -5,7 +5,6 @@ import { AudioPlayerProvider } from "../components/AudioPlayerContext";
import { OfflineNavidromeProvider, useOfflineNavidrome } from "../components/OfflineNavidromeProvider"; import { OfflineNavidromeProvider, useOfflineNavidrome } from "../components/OfflineNavidromeProvider";
import { NavidromeConfigProvider } from "../components/NavidromeConfigContext"; import { NavidromeConfigProvider } from "../components/NavidromeConfigContext";
import { ThemeProvider } from "../components/ThemeProvider"; import { ThemeProvider } from "../components/ThemeProvider";
import { PostHogProvider } from "../components/PostHogProvider";
import { WhatsNewPopup } from "../components/WhatsNewPopup"; import { WhatsNewPopup } from "../components/WhatsNewPopup";
import Ihateserverside from "./ihateserverside"; import Ihateserverside from "./ihateserverside";
import DynamicViewportTheme from "./DynamicViewportTheme"; import DynamicViewportTheme from "./DynamicViewportTheme";
@@ -101,7 +100,6 @@ function NavidromeErrorBoundary({ children }: { children: React.ReactNode }) {
export default function RootLayoutClient({ children }: { children: React.ReactNode }) { export default function RootLayoutClient({ children }: { children: React.ReactNode }) {
return ( return (
<PostHogProvider>
<ThemeProvider> <ThemeProvider>
<DynamicViewportTheme /> <DynamicViewportTheme />
<ThemeColorHandler /> <ThemeColorHandler />
@@ -121,6 +119,5 @@ export default function RootLayoutClient({ children }: { children: React.ReactNo
</OfflineNavidromeProvider> </OfflineNavidromeProvider>
</NavidromeConfigProvider> </NavidromeConfigProvider>
</ThemeProvider> </ThemeProvider>
</PostHogProvider>
); );
} }

View File

@@ -47,7 +47,6 @@ const CHANGELOG = [
'Enhanced Home page layout and content', 'Enhanced Home page layout and content',
'Themes updated to use OKLCH (from HSL)', 'Themes updated to use OKLCH (from HSL)',
'All themes updated (light themes look similar)', 'All themes updated (light themes look similar)',
'Caching system added (incomplete)',
'Skeleton loading added across all pages' 'Skeleton loading added across all pages'
], ],
fixes: [ fixes: [

View File

@@ -14,7 +14,6 @@ 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 EnhancedOfflineManager from '@/app/components/EnhancedOfflineManager'; import EnhancedOfflineManager from '@/app/components/EnhancedOfflineManager';
import { AutoTaggingSettings } from '@/app/components/AutoTaggingSettings'; import { AutoTaggingSettings } from '@/app/components/AutoTaggingSettings';
import { FaServer, FaUser, FaLock, FaCheck, FaTimes, FaLastfm, FaCog, FaTags } from 'react-icons/fa'; import { FaServer, FaUser, FaLock, FaCheck, FaTimes, FaLastfm, FaCog, FaTags } from 'react-icons/fa';
@@ -779,11 +778,6 @@ const SettingsPage = () => {
<SettingsManagement /> <SettingsManagement />
</div> </div>
{/* Cache Management */}
<div className="break-inside-avoid mb-6">
<CacheManagement />
</div>
{/* Offline Library Management */} {/* Offline Library Management */}
<div className="break-inside-avoid mb-6"> <div className="break-inside-avoid mb-6">
<EnhancedOfflineManager /> <EnhancedOfflineManager />

View File

@@ -12,10 +12,6 @@ services:
# - NAVIDROME_USERNAME=user # - NAVIDROME_USERNAME=user
# - NAVIDROME_PASSWORD=password # - NAVIDROME_PASSWORD=password
# # PostHog Analytics
# - POSTHOG_KEY=phc_Sa39J7754MwaHrPxYiWnWETVSD3g1cU4nOplMGczRE9
# - POSTHOG_HOST=https://us.i.posthog.com
# Application Port # Application Port
- PORT=40625 - PORT=40625

View File

@@ -14,8 +14,6 @@ services:
- NEXT_PUBLIC_NAVIDROME_URL=http://localhost:4533 - NEXT_PUBLIC_NAVIDROME_URL=http://localhost:4533
- NEXT_PUBLIC_NAVIDROME_USERNAME=admin - NEXT_PUBLIC_NAVIDROME_USERNAME=admin
- NEXT_PUBLIC_NAVIDROME_PASSWORD=admin - NEXT_PUBLIC_NAVIDROME_PASSWORD=admin
- NEXT_PUBLIC_POSTHOG_KEY=${POSTHOG_KEY:-}
- NEXT_PUBLIC_POSTHOG_HOST=${POSTHOG_HOST:-}
- PORT=${PORT:-3000} - PORT=${PORT:-3000}
# Mount source code for development (optional) # Mount source code for development (optional)

View File

@@ -13,10 +13,6 @@ services:
- NEXT_PUBLIC_NAVIDROME_USERNAME=${NAVIDROME_USERNAME:-} - NEXT_PUBLIC_NAVIDROME_USERNAME=${NAVIDROME_USERNAME:-}
- NEXT_PUBLIC_NAVIDROME_PASSWORD=${NAVIDROME_PASSWORD:-} - NEXT_PUBLIC_NAVIDROME_PASSWORD=${NAVIDROME_PASSWORD:-}
# PostHog Analytics (optional)
# - NEXT_PUBLIC_POSTHOG_KEY=phc_Sa39J7754MwaHrPxYiWnWETVSD3g1cU4nOplMGczRE9
# - NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
# Application Port # Application Port
- PORT=40625 - PORT=40625

View File

@@ -1,110 +0,0 @@
'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 };
}

View File

@@ -1,258 +0,0 @@
'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,5 +1,4 @@
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;

View File

@@ -1,11 +0,0 @@
import { PostHog } from "posthog-node"
export default function PostHogClient() {
const posthogClient = new PostHog(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
// capture_pageview: 'history_change',
flushAt: 1,
flushInterval: 0,
})
return posthogClient
}

View File

@@ -53,25 +53,6 @@ const nextConfig = {
}, },
]; ];
}, },
async rewrites() {
return [
{
source: '/ingest/static/:path*',
destination: 'https://us-assets.i.posthog.com/static/:path*',
},
{
source: '/ingest/:path*',
destination: 'https://us.i.posthog.com/:path*',
},
{
source: '/ingest/decide',
destination: 'https://us.i.posthog.com/decide',
},
];
},
// This is required to support PostHog trailing slash API requests
skipTrailingSlashRedirect: true,
}; };
export default nextConfig; export default nextConfig;

View File

@@ -13,88 +13,86 @@
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.2.0", "@hookform/resolvers": "^5.2.2",
"@radix-ui/react-accordion": "^1.2.11", "@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.14", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.7", "@radix-ui/react-aspect-ratio": "^1.1.8",
"@radix-ui/react-avatar": "^1.1.1", "@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.11", "@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.15", "@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.14", "@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.0", "@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-menubar": "^1.1.15", "@radix-ui/react-menubar": "^1.1.16",
"@radix-ui/react-navigation-menu": "^1.2.13", "@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.14", "@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.7", "@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.7", "@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.1", "@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.5", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "^1.3.5", "@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.1.1", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.5", "@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.12", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.4", "@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-toggle": "^1.1.9", "@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.10", "@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.7", "@radix-ui/react-tooltip": "^1.2.8",
"@types/react-beautiful-dnd": "^13.1.8", "@types/react-beautiful-dnd": "^13.1.8",
"axios": "^1.11.0", "axios": "^1.13.2",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"colorthief": "^2.6.0", "colorthief": "^2.6.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"framer-motion": "^11.18.2", "framer-motion": "^12.29.0",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"lucide-react": "^0.525.0", "lucide-react": "^0.563.0",
"next": "15.4.4", "next": "16.1.4",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"posthog-js": "^1.255.0", "react": "19.2.3",
"posthog-node": "^5.1.1", "react-day-picker": "^9.13.0",
"react": "19.1.0", "react-dom": "19.2.3",
"react-day-picker": "^9.7.0", "react-hook-form": "^7.71.1",
"react-dom": "19.1.0", "react-icons": "^5.5.0",
"react-hook-form": "^7.60.0", "react-intersection-observer": "^10.0.2",
"react-icons": "^5.3.0", "react-resizable-panels": "^4.5.1",
"react-intersection-observer": "^9.16.0", "recharts": "^3.7.0",
"react-resizable-panels": "^3.0.3", "sonner": "^2.0.7",
"recharts": "^3.0.2", "tailwind-merge": "^3.4.0",
"sonner": "^2.0.5",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"vaul": "^1.1.2", "vaul": "^1.1.2",
"zod": "^4.0.10" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.1.11", "@tailwindcss/postcss": "^4.1.18",
"@types/node": "^24.1.0", "@types/node": "^25.0.10",
"@types/react": "19.1.8", "@types/react": "19.2.9",
"@types/react-dom": "19.1.6", "@types/react-dom": "19.2.3",
"chalk": "^5.3.0", "chalk": "^5.6.2",
"eslint": "^9.32", "eslint": "^9.39.2",
"eslint-config-next": "15.4.5", "eslint-config-next": "16.1.4",
"postcss": "^8", "postcss": "^8.5.6",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
"tailwindcss": "^4.1.11", "tailwindcss": "^4.1.18",
"typescript": "^5" "typescript": "5.9.3"
}, },
"packageManager": "pnpm@10.13.1", "packageManager": "pnpm@10.13.1",
"overrides": { "overrides": {
"@types/react": "19.1.8", "@types/react": "19.2.9",
"@types/react-dom": "19.1.6", "@types/react-dom": "19.2.3",
"typescript": "5.9.2" "typescript": "5.9.3"
}, },
"pnpm": { "pnpm": {
"overrides": { "overrides": {
"@types/react": "19.1.8", "@types/react": "19.2.9",
"@types/react-dom": "19.1.6", "@types/react-dom": "19.2.3",
"typescript": "5.9.2" "typescript": "5.9.3"
}, },
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [
"sharp", "sharp",

3829
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff