From 3d7eb6e985f513b1d64599c2b190f8db89d5f45b Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Thu, 10 Jul 2025 21:32:14 -0700 Subject: [PATCH 1/7] refactor: consolidate now playing detection into single music stream store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Merge albumStream and nowPlayingStream into unified musicStream store - Simplify confidence scoring to binary detection (playing/not playing) - Create single source of truth for music state across components - Fix synchronization issues between header and album indicators - Make Album spring animation more subtle (stiffness: 150, damping: 25) 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/lib/components/Album.svelte | 50 +++---- src/lib/components/Avatar.svelte | 58 ++------ src/lib/components/Header.svelte | 40 ++---- src/lib/components/RecentAlbums.svelte | 4 +- src/lib/components/StreamStatus.svelte | 4 +- src/lib/stores/music-stream.ts | 156 +++++++++++++++++++++ src/lib/utils/simpleLastfmStreamManager.ts | 107 ++++++++++++++ src/lib/utils/simpleNowPlayingDetector.ts | 147 +++++++++++++++++++ src/routes/+layout.svelte | 4 + 9 files changed, 455 insertions(+), 115 deletions(-) create mode 100644 src/lib/stores/music-stream.ts create mode 100644 src/lib/utils/simpleLastfmStreamManager.ts create mode 100644 src/lib/utils/simpleNowPlayingDetector.ts diff --git a/src/lib/components/Album.svelte b/src/lib/components/Album.svelte index cac6695..ea72e0e 100644 --- a/src/lib/components/Album.svelte +++ b/src/lib/components/Album.svelte @@ -2,10 +2,9 @@ import { Spring } from 'svelte/motion' import type { Album } from '$lib/types/lastfm' import { audioPreview } from '$lib/stores/audio-preview' - import { nowPlayingStream } from '$lib/stores/now-playing-stream' import NowPlaying from './NowPlaying.svelte' - import PlayIcon from '$icons/play.svg' - import PauseIcon from '$icons/pause.svg' + import PlayIcon from '$icons/play.svg?component' + import PauseIcon from '$icons/pause.svg?component' interface AlbumProps { album?: Album @@ -32,8 +31,8 @@ }) const scale = new Spring(1, { - stiffness: 0.2, - damping: 0.12 + stiffness: 0.3, + damping: 0.25 }) // Determine if this album should shrink @@ -41,9 +40,9 @@ $effect(() => { if (isHovering) { - scale.target = 1.1 + scale.target = 1.05 } else if (shouldShrink) { - scale.target = 0.95 + scale.target = 0.97 } else { scale.target = 1 } @@ -99,32 +98,17 @@ const hasPreview = $derived(!!album?.appleMusicData?.previewUrl) - // Subscribe to real-time now playing updates - let realtimeNowPlaying = $state<{ isNowPlaying: boolean; nowPlayingTrack?: string } | null>(null) - - $effect(() => { - if (album) { - const unsubscribe = nowPlayingStream.isAlbumPlaying.subscribe((checkAlbum) => { - const status = checkAlbum(album.artist.name, album.name) - if (status !== null) { - realtimeNowPlaying = status - } - }) - return unsubscribe - } - }) - - // Combine initial state with real-time updates - const isNowPlaying = $derived(realtimeNowPlaying?.isNowPlaying ?? album?.isNowPlaying ?? false) - const nowPlayingTrack = $derived(realtimeNowPlaying?.nowPlayingTrack ?? album?.nowPlayingTrack) - + // Use the album's isNowPlaying status directly - single source of truth + const isNowPlaying = $derived(album?.isNowPlaying ?? false) + const nowPlayingTrack = $derived(album?.nowPlayingTrack) + // Debug logging $effect(() => { - if (album && isNowPlaying) { - console.log(`Album "${album.name}" is now playing:`, { - fromRealtime: realtimeNowPlaying?.isNowPlaying, - fromAlbum: album?.isNowPlaying, - track: nowPlayingTrack + if (album && (isNowPlaying || album.isNowPlaying)) { + console.log(`🎵 Album component "${album.name}":`, { + isNowPlaying, + nowPlayingTrack, + albumData: album }) } }) @@ -165,9 +149,9 @@ class:playing={isPlaying} > {#if isPlaying} - + {:else} - + {/if} {/if} diff --git a/src/lib/components/Avatar.svelte b/src/lib/components/Avatar.svelte index 6911608..d5a982d 100644 --- a/src/lib/components/Avatar.svelte +++ b/src/lib/components/Avatar.svelte @@ -1,11 +1,9 @@ diff --git a/src/lib/components/Header.svelte b/src/lib/components/Header.svelte index 8e86cef..e743c0b 100644 --- a/src/lib/components/Header.svelte +++ b/src/lib/components/Header.svelte @@ -3,8 +3,7 @@ import SegmentedController from './SegmentedController.svelte' import NavDropdown from './NavDropdown.svelte' import NowPlayingBar from './NowPlayingBar.svelte' - import { albumStream } from '$lib/stores/album-stream' - import { nowPlayingStream } from '$lib/stores/now-playing-stream' + import { musicStream } from '$lib/stores/music-stream' import type { Album } from '$lib/types/lastfm' let scrollY = $state(0) @@ -18,40 +17,19 @@ let currentlyPlayingAlbum = $state(null) let isPlayingMusic = $state(false) - // Subscribe to album updates + // Subscribe to music stream updates - single source of truth $effect(() => { - const unsubscribe = albumStream.subscribe((state) => { - const nowPlaying = state.albums.find((album) => album.isNowPlaying) - currentlyPlayingAlbum = nowPlaying || null + const unsubscribe = musicStream.nowPlaying.subscribe((nowPlaying) => { + currentlyPlayingAlbum = nowPlaying?.album || null isPlayingMusic = !!nowPlaying // Debug logging - if (nowPlaying) { - console.log('Header: Now playing detected:', { - artist: nowPlaying.artist.name, - album: nowPlaying.name, - track: nowPlaying.nowPlayingTrack - }) - } - }) - - return unsubscribe - }) - - // Also check now playing stream for updates - $effect(() => { - const unsubscribe = nowPlayingStream.subscribe((state) => { - const hasNowPlaying = Array.from(state.updates.values()).some((update) => update.isNowPlaying) - console.log('Header: nowPlayingStream update:', { - hasNowPlaying, - updatesCount: state.updates.size + console.log('🎧 Header now playing update:', { + hasNowPlaying: !!nowPlaying, + album: nowPlaying?.album.name, + artist: nowPlaying?.album.artist.name, + track: nowPlaying?.track }) - // Only clear if we explicitly know music stopped - if (!hasNowPlaying && currentlyPlayingAlbum && state.updates.size > 0) { - // Music stopped - currentlyPlayingAlbum = null - isPlayingMusic = false - } }) return unsubscribe diff --git a/src/lib/components/RecentAlbums.svelte b/src/lib/components/RecentAlbums.svelte index 715f4bb..9606961 100644 --- a/src/lib/components/RecentAlbums.svelte +++ b/src/lib/components/RecentAlbums.svelte @@ -1,7 +1,7 @@ + +{#if isOpen} + +{/if} + + \ No newline at end of file diff --git a/src/lib/components/DebugPanel.svelte b/src/lib/components/DebugPanel.svelte new file mode 100644 index 0000000..9543a6b --- /dev/null +++ b/src/lib/components/DebugPanel.svelte @@ -0,0 +1,1058 @@ + + +{#if dev} +
+
isMinimized = !isMinimized}> +

Debug Panel

+ +
+ + {#if !isMinimized} +
+
+ + + +
+ +
+ {#if activeTab === 'nowplaying'} +
+

Connection

+

+ Status: {#if connected} Connected{:else} Disconnected{/if} +

+

+ Last Update: {lastUpdate ? lastUpdate.toLocaleTimeString() : 'Never'} +

+

Next Update: {formatTime(nextUpdateIn)}

+

Interval: {updateInterval}s {trackRemainingTime > 0 ? `(smart mode)` : nowPlaying ? '(fast mode)' : '(normal)'}

+ {#if trackRemainingTime > 0} +

Track Remaining: {formatTime(trackRemainingTime)}

+ {/if} +
+ +
+

Now Playing

+ {#if nowPlaying} +
+

{nowPlaying.album.artist.name}

+

{nowPlaying.album.name}

+ {#if nowPlaying.track} +

{nowPlaying.track}

+ {/if} + {#if nowPlaying.album.appleMusicData} +

+ Preview: {#if nowPlaying.album.appleMusicData.previewUrl} Available{:else} Not found{/if} +

+ {/if} +
+ {:else} +

No music playing

+ {/if} +
+ {/if} + + {#if activeTab === 'albums'} +
+

Recent Albums ({albums.length})

+
+ {#each albums as album} + {@const albumId = `${album.artist.name}:${album.name}`} +
+
expandedAlbumId = expandedAlbumId === albumId ? null : albumId}> +
+
+ {album.name} + by {album.artist.name} +
+ {#if album.isNowPlaying} + NOW + {/if} +
+ {#if album.appleMusicData} + + {album.appleMusicData.tracks?.length || 0} tracks + + + {#if album.appleMusicData.previewUrl} Preview{:else} No preview{/if} + + {:else} + No Apple Music data + {/if} +
+
+ +
+ + {#if expandedAlbumId === albumId} +
+ {#if album.appleMusicData} + {#if album.appleMusicData.searchMetadata} +
Search Information
+ + {/if} + + {#if album.appleMusicData.appleMusicId} +
Apple Music Details
+

Apple Music ID: {album.appleMusicData.appleMusicId}

+ {/if} + + {#if album.appleMusicData.releaseDate} +

Release Date: {album.appleMusicData.releaseDate}

+ {/if} + + {#if album.appleMusicData.recordLabel} +

Label: {album.appleMusicData.recordLabel}

+ {/if} + + {#if album.appleMusicData.genres?.length} +

Genres: {album.appleMusicData.genres.join(', ')}

+ {/if} + + {#if album.appleMusicData.previewUrl} +

Preview URL: {album.appleMusicData.previewUrl}

+ {/if} + + {#if album.appleMusicData.tracks?.length} +
+
Tracks ({album.appleMusicData.tracks.length})
+
+ {#each album.appleMusicData.tracks as track, i} +
+ {i + 1}. + {track.name} + {#if track.durationMs} + {Math.floor(track.durationMs / 60000)}:{String(Math.floor((track.durationMs % 60000) / 1000)).padStart(2, '0')} + {/if} + {#if track.previewUrl} + + {/if} +
+ {/each} +
+
+ {/if} + +
+
Raw Data
+
{JSON.stringify(album.appleMusicData, null, 2)}
+
+ {:else} +
No Apple Music Data
+

This album was not searched in Apple Music or the search is pending.

+ {/if} +
+ {/if} +
+ {/each} +
+
+ {/if} + + {#if activeTab === 'cache'} +
+

Redis Cache Management

+ +
+ + +
+ +
+ + + + + +
+ +
+

Key format: apple:album:ArtistName:AlbumName

+

Example: apple:album:藤井風:Hachikō

+
+
+ {/if} +
+
+ {/if} +
+ + +{/if} + + \ No newline at end of file diff --git a/src/routes/api/admin/debug/apple-music-search/+server.ts b/src/routes/api/admin/debug/apple-music-search/+server.ts new file mode 100644 index 0000000..c82a96b --- /dev/null +++ b/src/routes/api/admin/debug/apple-music-search/+server.ts @@ -0,0 +1,33 @@ +import type { RequestHandler } from './$types' +import { searchAlbumsAndSongs } from '$lib/server/apple-music-client' +import { dev } from '$app/environment' + +export const POST: RequestHandler = async ({ request }) => { + // Only allow in development + if (!dev) { + return new Response('Not found', { status: 404 }) + } + + try { + const { query, storefront } = await request.json() + + if (!query) { + return new Response('Query is required', { status: 400 }) + } + + // Perform the search + const results = await searchAlbumsAndSongs(query, 25, storefront || 'us') + + return new Response(JSON.stringify(results), { + headers: { 'Content-Type': 'application/json' } + }) + } catch (error) { + console.error('Apple Music search error:', error) + return new Response(JSON.stringify({ + error: error instanceof Error ? error.message : 'Unknown error' + }), { + status: 500, + headers: { 'Content-Type': 'application/json' } + }) + } +} \ No newline at end of file diff --git a/src/routes/api/admin/debug/clear-cache/+server.ts b/src/routes/api/admin/debug/clear-cache/+server.ts new file mode 100644 index 0000000..15c28d9 --- /dev/null +++ b/src/routes/api/admin/debug/clear-cache/+server.ts @@ -0,0 +1,50 @@ +import type { RequestHandler } from './$types' +import redis from '../../../redis-client' +import { logger } from '$lib/server/logger' +import { dev } from '$app/environment' + +export const POST: RequestHandler = async ({ request }) => { + // Only allow in development + if (!dev) { + return new Response('Not found', { status: 404 }) + } + + try { + const { key, pattern } = await request.json() + + if (!key && !pattern) { + return new Response('Key or pattern is required', { status: 400 }) + } + + let deleted = 0 + + if (pattern) { + // Delete by pattern (e.g., "apple:album:*") + logger.music('debug', `Clearing cache by pattern: ${pattern}`) + + // Get all matching keys + const keys = await redis.keys(pattern) + + if (keys.length > 0) { + // Delete all matching keys + deleted = await redis.del(...keys) + logger.music('debug', `Deleted ${deleted} keys matching pattern: ${pattern}`) + } + } else if (key) { + // Delete specific key + logger.music('debug', `Clearing cache for key: ${key}`) + deleted = await redis.del(key) + } + + return new Response(JSON.stringify({ + success: true, + deleted, + key: key || pattern + }), { + headers: { 'Content-Type': 'application/json' } + }) + } catch (error) { + logger.error('Failed to clear cache:', error as Error) + return new Response('Internal server error', { status: 500 }) + } +} \ No newline at end of file diff --git a/src/routes/api/admin/debug/redis-keys/+server.ts b/src/routes/api/admin/debug/redis-keys/+server.ts new file mode 100644 index 0000000..59cafc1 --- /dev/null +++ b/src/routes/api/admin/debug/redis-keys/+server.ts @@ -0,0 +1,41 @@ +import type { RequestHandler } from './$types' +import redis from '../../../redis-client' +import { dev } from '$app/environment' + +export const GET: RequestHandler = async ({ url }) => { + // Only allow in development + if (!dev) { + return new Response('Not found', { status: 404 }) + } + + try { + const pattern = url.searchParams.get('pattern') || '*' + + // Get all keys matching pattern + const keys = await redis.keys(pattern) + + // Get values for each key (limit to first 100 to avoid overload) + const keysWithValues = await Promise.all( + keys.slice(0, 100).map(async (key) => { + const value = await redis.get(key) + const ttl = await redis.ttl(key) + return { + key, + value: value ? (value.length > 200 ? value.substring(0, 200) + '...' : value) : null, + ttl + } + }) + ) + + return new Response(JSON.stringify({ + total: keys.length, + showing: keysWithValues.length, + keys: keysWithValues + }), { + headers: { 'Content-Type': 'application/json' } + }) + } catch (error) { + console.error('Failed to get Redis keys:', error) + return new Response('Internal server error', { status: 500 }) + } +} \ No newline at end of file diff --git a/src/routes/api/admin/debug/test-find-album/+server.ts b/src/routes/api/admin/debug/test-find-album/+server.ts new file mode 100644 index 0000000..c4013b3 --- /dev/null +++ b/src/routes/api/admin/debug/test-find-album/+server.ts @@ -0,0 +1,33 @@ +import type { RequestHandler } from './$types' +import { findAlbum } from '$lib/server/apple-music-client' +import { dev } from '$app/environment' + +export const GET: RequestHandler = async ({ url }) => { + if (!dev) { + return new Response('Not found', { status: 404 }) + } + + const artist = url.searchParams.get('artist') || '藤井風' + const album = url.searchParams.get('album') || 'Hachikō' + + console.log(`Testing findAlbum for "${album}" by "${artist}"`) + + try { + const result = await findAlbum(artist, album) + return new Response(JSON.stringify({ + artist, + album, + found: !!result, + result + }), { + headers: { 'Content-Type': 'application/json' } + }) + } catch (error) { + return new Response(JSON.stringify({ + error: error instanceof Error ? error.message : 'Unknown error' + }), { + status: 500, + headers: { 'Content-Type': 'application/json' } + }) + } +} \ No newline at end of file diff --git a/src/routes/api/admin/debug/test-simple-search/+server.ts b/src/routes/api/admin/debug/test-simple-search/+server.ts new file mode 100644 index 0000000..ee5b013 --- /dev/null +++ b/src/routes/api/admin/debug/test-simple-search/+server.ts @@ -0,0 +1,55 @@ +import type { RequestHandler } from './$types' +import { searchAlbumsAndSongs } from '$lib/server/apple-music-client' +import { dev } from '$app/environment' + +export const GET: RequestHandler = async () => { + if (!dev) { + return new Response('Not found', { status: 404 }) + } + + // Test simple search + const searchQuery = '藤井風 Hachikō' + console.log(`Testing simple search for: ${searchQuery}`) + + try { + // Search in both storefronts + const jpResults = await searchAlbumsAndSongs(searchQuery, 5, 'jp') + const usResults = await searchAlbumsAndSongs(searchQuery, 5, 'us') + + // Check if we found the song in either storefront + const jpSongs = jpResults.results?.songs?.data || [] + const usSongs = usResults.results?.songs?.data || [] + + const hachiko = [...jpSongs, ...usSongs].find(s => + s.attributes?.name?.toLowerCase() === 'hachikō' && + s.attributes?.artistName?.includes('藤井') + ) + + return new Response(JSON.stringify({ + searchQuery, + jpSongsFound: jpSongs.length, + usSongsFound: usSongs.length, + hachikoFound: !!hachiko, + hachikoDetails: hachiko ? { + name: hachiko.attributes?.name, + artist: hachiko.attributes?.artistName, + album: hachiko.attributes?.albumName, + preview: hachiko.attributes?.previews?.[0]?.url + } : null, + allSongs: [...jpSongs, ...usSongs].map(s => ({ + name: s.attributes?.name, + artist: s.attributes?.artistName, + album: s.attributes?.albumName + })) + }), { + headers: { 'Content-Type': 'application/json' } + }) + } catch (error) { + return new Response(JSON.stringify({ + error: error instanceof Error ? error.message : 'Unknown error' + }), { + status: 500, + headers: { 'Content-Type': 'application/json' } + }) + } +} \ No newline at end of file From f17934dcb89311c73d05eb78fe664ed89632f8a8 Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Thu, 10 Jul 2025 21:33:12 -0700 Subject: [PATCH 5/7] feat: add SVG icons for debug panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add check, x, trash, settings, clock, and loader icons - Replace emoji with proper SVG icons in DebugPanel - Improve visual consistency and cross-platform compatibility 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/assets/icons/check.svg | 3 +++ src/assets/icons/clock.svg | 4 ++++ src/assets/icons/loader.svg | 3 +++ src/assets/icons/settings.svg | 4 ++++ src/assets/icons/trash.svg | 3 +++ src/assets/icons/x.svg | 3 +++ 6 files changed, 20 insertions(+) create mode 100644 src/assets/icons/check.svg create mode 100644 src/assets/icons/clock.svg create mode 100644 src/assets/icons/loader.svg create mode 100644 src/assets/icons/settings.svg create mode 100644 src/assets/icons/trash.svg create mode 100644 src/assets/icons/x.svg diff --git a/src/assets/icons/check.svg b/src/assets/icons/check.svg new file mode 100644 index 0000000..f654ec4 --- /dev/null +++ b/src/assets/icons/check.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/assets/icons/clock.svg b/src/assets/icons/clock.svg new file mode 100644 index 0000000..45f7c20 --- /dev/null +++ b/src/assets/icons/clock.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/assets/icons/loader.svg b/src/assets/icons/loader.svg new file mode 100644 index 0000000..7904da8 --- /dev/null +++ b/src/assets/icons/loader.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/assets/icons/settings.svg b/src/assets/icons/settings.svg new file mode 100644 index 0000000..6284452 --- /dev/null +++ b/src/assets/icons/settings.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/assets/icons/trash.svg b/src/assets/icons/trash.svg new file mode 100644 index 0000000..b95165c --- /dev/null +++ b/src/assets/icons/trash.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/assets/icons/x.svg b/src/assets/icons/x.svg new file mode 100644 index 0000000..05eacec --- /dev/null +++ b/src/assets/icons/x.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file From a8997382b7b43de422f282482295f67e3b49024d Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Thu, 10 Jul 2025 21:33:25 -0700 Subject: [PATCH 6/7] feat: add centralized cache management system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create CacheManager class to unify all Redis cache operations - Define cache types with prefixes and default TTLs - Provide type-safe cache operations - Add bulk clear operations for related caches - Include cache statistics and monitoring capabilities 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/lib/server/cache-manager.ts | 165 ++++++++++++++++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 src/lib/server/cache-manager.ts diff --git a/src/lib/server/cache-manager.ts b/src/lib/server/cache-manager.ts new file mode 100644 index 0000000..2ef1458 --- /dev/null +++ b/src/lib/server/cache-manager.ts @@ -0,0 +1,165 @@ +import redis from '../../routes/api/redis-client' +import { logger } from './logger' + +export interface CacheConfig { + prefix: string + defaultTTL: number + description: string +} + +export class CacheManager { + private static cacheTypes: Map = new Map([ + ['lastfm-recent', { prefix: 'lastfm:recent:', defaultTTL: 30, description: 'Last.fm recent tracks' }], + ['lastfm-album', { prefix: 'lastfm:albuminfo:', defaultTTL: 3600, description: 'Last.fm album info' }], + ['apple-album', { prefix: 'apple:album:', defaultTTL: 86400, description: 'Apple Music album data' }], + ['apple-notfound', { prefix: 'notfound:apple-music:', defaultTTL: 3600, description: 'Apple Music not found records' }], + ['apple-failure', { prefix: 'failure:apple-music:', defaultTTL: 86400, description: 'Apple Music API failures' }], + ['apple-ratelimit', { prefix: 'ratelimit:apple-music:', defaultTTL: 3600, description: 'Apple Music rate limit state' }] + ]) + + /** + * Get a value from cache + */ + static async get(type: string, key: string): Promise { + const config = this.cacheTypes.get(type) + if (!config) { + logger.error(`Unknown cache type: ${type}`) + return null + } + + const fullKey = `${config.prefix}${key}` + return await redis.get(fullKey) + } + + /** + * Set a value in cache + */ + static async set(type: string, key: string, value: string, ttl?: number): Promise { + const config = this.cacheTypes.get(type) + if (!config) { + logger.error(`Unknown cache type: ${type}`) + return + } + + const fullKey = `${config.prefix}${key}` + const expiry = ttl || config.defaultTTL + + await redis.set(fullKey, value, 'EX', expiry) + logger.music('debug', `Cached ${type} for key: ${key} (TTL: ${expiry}s)`) + } + + /** + * Delete a specific cache entry + */ + static async delete(type: string, key: string): Promise { + const config = this.cacheTypes.get(type) + if (!config) { + logger.error(`Unknown cache type: ${type}`) + return false + } + + const fullKey = `${config.prefix}${key}` + const deleted = await redis.del(fullKey) + return deleted > 0 + } + + /** + * Clear all entries for a specific cache type + */ + static async clearType(type: string): Promise { + const config = this.cacheTypes.get(type) + if (!config) { + logger.error(`Unknown cache type: ${type}`) + return 0 + } + + const pattern = `${config.prefix}*` + const keys = await redis.keys(pattern) + + if (keys.length === 0) return 0 + + const deleted = await redis.del(...keys) + logger.music('info', `Cleared ${deleted} entries from ${type} cache`) + return deleted + } + + /** + * Clear all entries matching a pattern within a cache type + */ + static async clearPattern(type: string, pattern: string): Promise { + const config = this.cacheTypes.get(type) + if (!config) { + logger.error(`Unknown cache type: ${type}`) + return 0 + } + + const searchPattern = `${config.prefix}*${pattern}*` + const keys = await redis.keys(searchPattern) + + if (keys.length === 0) return 0 + + const deleted = await redis.del(...keys) + logger.music('info', `Cleared ${deleted} entries matching "${pattern}" from ${type} cache`) + return deleted + } + + /** + * Clear all caches for a specific album + */ + static async clearAlbum(artist: string, album: string): Promise { + const albumKey = `${artist}:${album}` + let totalDeleted = 0 + + // Clear all cache types that might contain this album + for (const [type, config] of this.cacheTypes) { + if (type.includes('album') || type.includes('notfound')) { + const deleted = await this.clearPattern(type, albumKey) + totalDeleted += deleted + } + } + + logger.music('info', `Cleared ${totalDeleted} cache entries for album "${album}" by "${artist}"`) + return totalDeleted + } + + /** + * Get all cache types and their info + */ + static getCacheTypes(): Array<{ type: string; config: CacheConfig }> { + return Array.from(this.cacheTypes.entries()).map(([type, config]) => ({ type, config })) + } + + /** + * Get cache statistics + */ + static async getStats(): Promise> { + const stats = [] + + for (const [type, config] of this.cacheTypes) { + const keys = await redis.keys(`${config.prefix}*`) + stats.push({ + type, + count: keys.length, + description: config.description + }) + } + + return stats + } +} + +// Export convenience functions for common operations +export const cache = { + lastfm: { + getRecent: (username: string) => CacheManager.get('lastfm-recent', username), + setRecent: (username: string, data: string) => CacheManager.set('lastfm-recent', username, data), + getAlbum: (artist: string, album: string) => CacheManager.get('lastfm-album', `${artist}:${album}`), + setAlbum: (artist: string, album: string, data: string) => CacheManager.set('lastfm-album', `${artist}:${album}`, data) + }, + apple: { + getAlbum: (artist: string, album: string) => CacheManager.get('apple-album', `${artist}:${album}`), + setAlbum: (artist: string, album: string, data: string, ttl?: number) => CacheManager.set('apple-album', `${artist}:${album}`, data, ttl), + isNotFound: (artist: string, album: string) => CacheManager.get('apple-notfound', `${artist}:${album}`), + markNotFound: (artist: string, album: string, ttl?: number) => CacheManager.set('apple-notfound', `${artist}:${album}`, '1', ttl) + } +} \ No newline at end of file From fd56b1492f945c55a50a9c8cbedc58c38214f57f Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Thu, 10 Jul 2025 21:34:50 -0700 Subject: [PATCH 7/7] chore: add server.log to .gitignore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 99d9898..0898abc 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,4 @@ vite.config.ts.timestamp-* *storybook.log storybook-static backups/ +server.log