From 5da6f4c7367e218f68d6913e96f039eb90cab1f2 Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Tue, 17 Jun 2025 08:13:43 +0100 Subject: [PATCH] Fix Cloudinary media audit --- README.md | 3 + src/lib/server/apple-music-client.ts | 48 ++++----- src/lib/server/logger.ts | 66 ++++++++++--- src/routes/admin/media/audit/+page.svelte | 113 ++++++++++++++++------ src/routes/api/lastfm/+server.ts | 9 +- src/routes/api/lastfm/stream/+server.ts | 43 ++++---- 6 files changed, 191 insertions(+), 91 deletions(-) diff --git a/README.md b/README.md index 535ae87..247f126 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,9 @@ Required environment variables: - `LASTFM_API_KEY` - Last.fm API key for music data - `REDIS_URL` - Redis connection URL for caching +Optional environment variables: +- `DEBUG` - Enable debug logging for specific categories (e.g., `DEBUG=music` for music-related logs) + ## Commands - `npm run dev` - Start development server diff --git a/src/lib/server/apple-music-client.ts b/src/lib/server/apple-music-client.ts index 57a163d..221798d 100644 --- a/src/lib/server/apple-music-client.ts +++ b/src/lib/server/apple-music-client.ts @@ -7,6 +7,7 @@ import type { } from '$lib/types/apple-music' import { isAppleMusicError } from '$lib/types/apple-music' import { ApiRateLimiter } from './rate-limiter' +import { logger } from './logger' const APPLE_MUSIC_API_BASE = 'https://api.music.apple.com/v1' const DEFAULT_STOREFRONT = 'us' // Default to US storefront @@ -37,7 +38,7 @@ async function makeAppleMusicRequest(endpoint: string, identifier?: string): const url = `${APPLE_MUSIC_API_BASE}${endpoint}` const headers = getAppleMusicHeaders() - console.log('Making Apple Music API request:', { + logger.music('debug', 'Making Apple Music API request:', { url, headers: { ...headers, @@ -50,11 +51,11 @@ async function makeAppleMusicRequest(endpoint: string, identifier?: string): if (!response.ok) { const errorText = await response.text() - console.error('Apple Music API error response:', { + logger.error('Apple Music API error response:', undefined, { status: response.status, statusText: response.statusText, body: errorText - }) + }, 'music') // Record failure and handle rate limiting if (identifier) { @@ -82,7 +83,7 @@ async function makeAppleMusicRequest(endpoint: string, identifier?: string): return await response.json() } catch (error) { - console.error('Apple Music API request failed:', error) + logger.error('Apple Music API request failed:', error as Error, undefined, 'music') throw error } } @@ -127,7 +128,7 @@ export async function getAlbumDetails(id: string): Promise(endpoint, `album:${id}`) - console.log(`Album details for ${id}:`, { + logger.music('debug', `Album details for ${id}:`, { hasData: !!response.data?.[0], hasRelationships: !!response.data?.[0]?.relationships, hasTracks: !!response.data?.[0]?.relationships?.tracks, @@ -137,12 +138,12 @@ export async function getAlbumDetails(id: string): Promise 0) { - console.log( + logger.music('debug', `No match found for "${album}", trying without leading punctuation: "${cleanedAlbum}"` ) result = await searchAndMatch(cleanedAlbum) // Also try Japanese storefront with cleaned album name if (!result) { - console.log(`Still no match, trying Japanese storefront with cleaned name`) + logger.music('debug', `Still no match, trying Japanese storefront with cleaned name`) result = await searchAndMatch(cleanedAlbum, JAPANESE_STOREFRONT) } } @@ -258,7 +258,7 @@ export async function findAlbum(artist: string, album: string): Promise(endpoint, `album:${appleMusicAlbum.id}`) - console.log(`Album details response structure:`, { + logger.music('debug', `Album details response structure:`, { hasData: !!response.data, dataLength: response.data?.length, hasIncluded: !!response.included, @@ -300,7 +300,7 @@ export async function transformAlbumData(appleMusicAlbum: AppleMusicAlbum) { const tracksData = albumData?.relationships?.tracks?.data if (tracksData?.length) { - console.log(`Found ${tracksData.length} tracks for album "${attributes.name}"`) + logger.music('debug', `Found ${tracksData.length} tracks for album "${attributes.name}"`) // Process all tracks tracks = tracksData @@ -313,7 +313,7 @@ export async function transformAlbumData(appleMusicAlbum: AppleMusicAlbum) { // Log track details tracks.forEach((track, index) => { - console.log( + logger.music('debug', `Track ${index + 1}: ${track.name} - Preview: ${track.previewUrl ? 'Yes' : 'No'} - Duration: ${track.durationMs}ms` ) }) @@ -323,16 +323,16 @@ export async function transformAlbumData(appleMusicAlbum: AppleMusicAlbum) { for (const track of tracksData) { if (track.type === 'songs' && track.attributes?.previews?.[0]?.url) { previewUrl = track.attributes.previews[0].url - console.log(`Using preview URL from track "${track.attributes.name}"`) + logger.music('debug', `Using preview URL from track "${track.attributes.name}"`) break } } } } else { - console.log('No tracks found in album response') + logger.music('debug', 'No tracks found in album response') } } catch (error) { - console.error('Failed to fetch album tracks:', error) + logger.error('Failed to fetch album tracks:', error as Error, undefined, 'music') } } diff --git a/src/lib/server/logger.ts b/src/lib/server/logger.ts index 82d297e..140f691 100644 --- a/src/lib/server/logger.ts +++ b/src/lib/server/logger.ts @@ -1,6 +1,7 @@ import { dev } from '$app/environment' export type LogLevel = 'debug' | 'info' | 'warn' | 'error' +export type LogCategory = 'music' | 'api' | 'db' | 'media' | 'general' interface LogEntry { level: LogLevel @@ -8,19 +9,46 @@ interface LogEntry { timestamp: string context?: Record error?: Error + category?: LogCategory } class Logger { - private shouldLog(level: LogLevel): boolean { - // In development, log everything - if (dev) return true + private debugCategories: Set = new Set() + + constructor() { + // Parse DEBUG environment variable to enable specific categories + const debugEnv = process.env.DEBUG || '' + if (debugEnv) { + const categories = debugEnv.split(',').map(c => c.trim()) as LogCategory[] + categories.forEach(cat => this.debugCategories.add(cat)) + } + } + + private shouldLog(level: LogLevel, category?: LogCategory): boolean { + // Always log warnings and errors + if (level === 'warn' || level === 'error') return true + + // In development, check if category debugging is enabled + if (dev && category && this.debugCategories.size > 0) { + return this.debugCategories.has(category) + } + + // In development without category debugging, log everything except music logs + if (dev && !category) return true + if (dev && category === 'music') return this.debugCategories.has('music') // In production, only log warnings and errors - return level === 'warn' || level === 'error' + return false } private formatLog(entry: LogEntry): string { - const parts = [`[${entry.timestamp}]`, `[${entry.level.toUpperCase()}]`, entry.message] + const parts = [`[${entry.timestamp}]`, `[${entry.level.toUpperCase()}]`] + + if (entry.category) { + parts.push(`[${entry.category.toUpperCase()}]`) + } + + parts.push(entry.message) if (entry.context) { parts.push(JSON.stringify(entry.context, null, 2)) @@ -36,15 +64,16 @@ class Logger { return parts.join(' ') } - private log(level: LogLevel, message: string, context?: Record, error?: Error) { - if (!this.shouldLog(level)) return + private log(level: LogLevel, message: string, context?: Record, error?: Error, category?: LogCategory) { + if (!this.shouldLog(level, category)) return const entry: LogEntry = { level, message, timestamp: new Date().toISOString(), context, - error + error, + category } const formatted = this.formatLog(entry) @@ -63,20 +92,25 @@ class Logger { } } - debug(message: string, context?: Record) { - this.log('debug', message, context) + debug(message: string, context?: Record, category?: LogCategory) { + this.log('debug', message, context, undefined, category) } - info(message: string, context?: Record) { - this.log('info', message, context) + info(message: string, context?: Record, category?: LogCategory) { + this.log('info', message, context, undefined, category) } - warn(message: string, context?: Record) { - this.log('warn', message, context) + warn(message: string, context?: Record, category?: LogCategory) { + this.log('warn', message, context, undefined, category) } - error(message: string, error?: Error, context?: Record) { - this.log('error', message, context, error) + error(message: string, error?: Error, context?: Record, category?: LogCategory) { + this.log('error', message, context, error, category) + } + + // Convenience method for music-related logs + music(level: LogLevel, message: string, context?: Record) { + this.log(level, message, context, undefined, 'music') } // Log API requests diff --git a/src/routes/admin/media/audit/+page.svelte b/src/routes/admin/media/audit/+page.svelte index 68b2e13..d5d2799 100644 --- a/src/routes/admin/media/audit/+page.svelte +++ b/src/routes/admin/media/audit/+page.svelte @@ -50,6 +50,8 @@ auditData?.orphanedFiles .filter((f) => selectedFiles.has(f.publicId)) .reduce((sum, f) => sum + f.size, 0) || 0 + + $: console.log('Reactive state:', { hasSelection, selectedFilesSize: selectedFiles.size, deleting, showDeleteModal, showCleanupModal }) onMount(() => { runAudit() @@ -93,12 +95,14 @@ } function toggleFile(publicId: string) { + console.log('toggleFile called', publicId) if (selectedFiles.has(publicId)) { selectedFiles.delete(publicId) } else { selectedFiles.add(publicId) } selectedFiles = selectedFiles // Trigger reactivity + console.log('selectedFiles after toggle:', Array.from(selectedFiles)) } async function deleteSelected(dryRun = true) { @@ -286,13 +290,16 @@ {/if}
- - + +
+ +
+

Are you sure you want to delete {selectedFiles.size} orphaned files?

+

This will free up {formatBytes(selectedSize)} of storage.

+

⚠️ This action cannot be undone.

+
+
- -
-

Are you sure you want to clean up {auditData?.missingReferences.length || 0} broken references?

-

⚠️ This will remove Cloudinary URLs from database records where the files no longer exist.

-

This action cannot be undone.

-
-
- - + +
+ +
+

Are you sure you want to clean up {auditData?.missingReferences.length || 0} broken references?

+

⚠️ This will remove Cloudinary URLs from database records where the files no longer exist.

+

This action cannot be undone.

+
+
@@ -815,6 +847,33 @@ } } + .modal-header { + margin-bottom: 1rem; + + h2 { + margin: 0; + font-size: 1.25rem; + font-weight: 600; + color: $grey-10; + } + } + + .audit-modal-content { + display: flex; + flex-direction: column; + padding: 1.5rem; + min-width: 400px; + } + + .modal-actions { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + margin-top: 1.5rem; + padding-top: 1.5rem; + border-top: 1px solid $grey-90; + } + @keyframes spin { to { transform: rotate(360deg); diff --git a/src/routes/api/lastfm/+server.ts b/src/routes/api/lastfm/+server.ts index adb6cf9..1a9373a 100644 --- a/src/routes/api/lastfm/+server.ts +++ b/src/routes/api/lastfm/+server.ts @@ -5,6 +5,7 @@ import type { Album, AlbumImages } from '$lib/types/lastfm' import type { LastfmImage } from '@musicorum/lastfm/dist/types/packages/common' import { findAlbum, transformAlbumData } from '$lib/server/apple-music-client' import redis from '../redis-client' +import { logger } from '$lib/server/logger' const LASTFM_API_KEY = process.env.LASTFM_API_KEY const USERNAME = 'jedmund' @@ -35,7 +36,7 @@ export const GET: RequestHandler = async ({ url }) => { return await enrichAlbumWithInfo(client, album) } catch (error) { if (error instanceof Error && error.message.includes('Album not found')) { - console.debug(`Skipping album: ${album.name} (Album not found)`) + logger.music('debug', `Skipping album: ${album.name} (Album not found)`) return null // Skip the album } throw error // Re-throw if it's a different error @@ -78,7 +79,7 @@ async function getRecentAlbums( let recentTracksResponse if (cached) { - console.log('Using cached Last.fm recent tracks') + logger.music('debug', 'Using cached Last.fm recent tracks') recentTracksResponse = JSON.parse(cached) // Convert date strings back to Date objects if (recentTracksResponse.tracks) { @@ -162,7 +163,7 @@ async function enrichAlbumWithInfo(client: LastClient, album: Album): Promise { if (cached) { const cachedData = JSON.parse(cached) - console.log(`Using cached data for "${album.name}":`, { + logger.music('debug', `Using cached data for "${album.name}":`, { hasPreview: !!cachedData.previewUrl, trackCount: cachedData.tracks?.length || 0 }) diff --git a/src/routes/api/lastfm/stream/+server.ts b/src/routes/api/lastfm/stream/+server.ts index c8f9fa8..be33c43 100644 --- a/src/routes/api/lastfm/stream/+server.ts +++ b/src/routes/api/lastfm/stream/+server.ts @@ -4,6 +4,7 @@ import type { Album, AlbumImages } from '$lib/types/lastfm' import type { LastfmImage } from '@musicorum/lastfm/dist/types/packages/common' import { findAlbum, transformAlbumData } from '$lib/server/apple-music-client' import redis from '../../redis-client' +import { logger } from '$lib/server/logger' const LASTFM_API_KEY = process.env.LASTFM_API_KEY const USERNAME = 'jedmund' @@ -41,7 +42,7 @@ export const GET: RequestHandler = async ({ request }) => { try { controller.enqueue(encoder.encode('event: connected\ndata: {}\n\n')) } catch (e) { - console.error('Failed to send initial message:', e) + logger.error('Failed to send initial message:', e as Error, undefined, 'music') return } @@ -81,7 +82,7 @@ export const GET: RequestHandler = async ({ request }) => { return withAppleMusic } catch (error) { - console.error(`Error enriching album ${album.name}:`, error) + logger.error(`Error enriching album ${album.name}:`, error as Error, undefined, 'music') return album } }) @@ -90,7 +91,7 @@ export const GET: RequestHandler = async ({ request }) => { // Ensure only one album is marked as now playing in the enriched albums const nowPlayingCount = enrichedAlbums.filter((a) => a.isNowPlaying).length if (nowPlayingCount > 1) { - console.log( + logger.music('debug', `Multiple enriched albums marked as now playing (${nowPlayingCount}), keeping only the most recent one` ) @@ -100,11 +101,11 @@ export const GET: RequestHandler = async ({ request }) => { enrichedAlbums.forEach((album, index) => { if (album.isNowPlaying) { if (foundFirst) { - console.log(`Marking album "${album.name}" at position ${index} as not playing`) + logger.music('debug', `Marking album "${album.name}" at position ${index} as not playing`) album.isNowPlaying = false album.nowPlayingTrack = undefined } else { - console.log(`Keeping album "${album.name}" at position ${index} as now playing`) + logger.music('debug', `Keeping album "${album.name}" at position ${index} as now playing`) foundFirst = true } } @@ -148,7 +149,7 @@ export const GET: RequestHandler = async ({ request }) => { const data = JSON.stringify(enrichedAlbums) controller.enqueue(encoder.encode(`event: albums\ndata: ${data}\n\n`)) const nowPlayingAlbum = enrichedAlbums.find((a) => a.isNowPlaying) - console.log('Sent album update with now playing status:', { + logger.music('debug', 'Sent album update with now playing status:', { totalAlbums: enrichedAlbums.length, nowPlayingAlbum: nowPlayingAlbum ? `${nowPlayingAlbum.artist.name} - ${nowPlayingAlbum.name}` @@ -183,7 +184,7 @@ export const GET: RequestHandler = async ({ request }) => { (album.isNowPlaying && album.nowPlayingTrack !== lastTrack) ) { updates.push(album) - console.log( + logger.music('debug', `Now playing update for non-recent album ${album.albumName}: playing=${album.isNowPlaying}, track=${album.nowPlayingTrack}` ) } @@ -217,7 +218,7 @@ export const GET: RequestHandler = async ({ request }) => { } } } catch (error) { - console.error('Error checking for updates:', error) + logger.error('Error checking for updates:', error as Error, undefined, 'music') } } @@ -243,7 +244,7 @@ export const GET: RequestHandler = async ({ request }) => { cancel() { // Cleanup when stream is cancelled - console.log('SSE stream cancelled') + logger.music('debug', 'SSE stream cancelled') } }) @@ -264,7 +265,7 @@ async function getNowPlayingAlbums(client: LastClient): Promise a.isNowPlaying) if (nowPlayingAlbums.length > 1) { - console.log( + logger.music('debug', `Multiple albums marked as now playing (${nowPlayingAlbums.length}), keeping only the most recent one` ) @@ -393,7 +394,7 @@ async function checkNowPlayingWithDuration( const appleMusicData = JSON.parse(cached) return checkWithTracks(albumName, appleMusicData.tracks) } catch (error) { - console.error(`Error checking duration for ${albumName}:`, error) + logger.error(`Error checking duration for ${albumName}:`, error as Error, undefined, 'music') return null } } @@ -435,7 +436,7 @@ function checkWithTracks( ) if (now < trackEndTime) { - console.log( + logger.music('debug', `Track "${mostRecentTrack.trackName}" is still playing (ends at ${trackEndTime.toLocaleTimeString()})` ) return { @@ -476,7 +477,7 @@ async function enrichAlbumWithInfo(client: LastClient, album: Album): Promise { } } } catch (error) { - console.error( + logger.error( `Failed to fetch Apple Music data for "${album.name}" by "${album.artist.name}":`, - error + error as Error, + undefined, + 'music' ) } @@ -552,7 +555,7 @@ async function getRecentAlbums(client: LastClient, limit: number = 4): Promise