Fix Cloudinary media audit

This commit is contained in:
Justin Edmund 2025-06-17 08:13:43 +01:00
parent fdf1ce5e21
commit 5da6f4c736
6 changed files with 191 additions and 91 deletions

View file

@ -42,6 +42,9 @@ Required environment variables:
- `LASTFM_API_KEY` - Last.fm API key for music data - `LASTFM_API_KEY` - Last.fm API key for music data
- `REDIS_URL` - Redis connection URL for caching - `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 ## Commands
- `npm run dev` - Start development server - `npm run dev` - Start development server

View file

@ -7,6 +7,7 @@ import type {
} from '$lib/types/apple-music' } from '$lib/types/apple-music'
import { isAppleMusicError } from '$lib/types/apple-music' import { isAppleMusicError } from '$lib/types/apple-music'
import { ApiRateLimiter } from './rate-limiter' import { ApiRateLimiter } from './rate-limiter'
import { logger } from './logger'
const APPLE_MUSIC_API_BASE = 'https://api.music.apple.com/v1' const APPLE_MUSIC_API_BASE = 'https://api.music.apple.com/v1'
const DEFAULT_STOREFRONT = 'us' // Default to US storefront const DEFAULT_STOREFRONT = 'us' // Default to US storefront
@ -37,7 +38,7 @@ async function makeAppleMusicRequest<T>(endpoint: string, identifier?: string):
const url = `${APPLE_MUSIC_API_BASE}${endpoint}` const url = `${APPLE_MUSIC_API_BASE}${endpoint}`
const headers = getAppleMusicHeaders() const headers = getAppleMusicHeaders()
console.log('Making Apple Music API request:', { logger.music('debug', 'Making Apple Music API request:', {
url, url,
headers: { headers: {
...headers, ...headers,
@ -50,11 +51,11 @@ async function makeAppleMusicRequest<T>(endpoint: string, identifier?: string):
if (!response.ok) { if (!response.ok) {
const errorText = await response.text() const errorText = await response.text()
console.error('Apple Music API error response:', { logger.error('Apple Music API error response:', undefined, {
status: response.status, status: response.status,
statusText: response.statusText, statusText: response.statusText,
body: errorText body: errorText
}) }, 'music')
// Record failure and handle rate limiting // Record failure and handle rate limiting
if (identifier) { if (identifier) {
@ -82,7 +83,7 @@ async function makeAppleMusicRequest<T>(endpoint: string, identifier?: string):
return await response.json() return await response.json()
} catch (error) { } catch (error) {
console.error('Apple Music API request failed:', error) logger.error('Apple Music API request failed:', error as Error, undefined, 'music')
throw error throw error
} }
} }
@ -127,7 +128,7 @@ export async function getAlbumDetails(id: string): Promise<AppleMusicAlbum | nul
included?: AppleMusicTrack[] included?: AppleMusicTrack[]
}>(endpoint, `album:${id}`) }>(endpoint, `album:${id}`)
console.log(`Album details for ${id}:`, { logger.music('debug', `Album details for ${id}:`, {
hasData: !!response.data?.[0], hasData: !!response.data?.[0],
hasRelationships: !!response.data?.[0]?.relationships, hasRelationships: !!response.data?.[0]?.relationships,
hasTracks: !!response.data?.[0]?.relationships?.tracks, hasTracks: !!response.data?.[0]?.relationships?.tracks,
@ -137,12 +138,12 @@ export async function getAlbumDetails(id: string): Promise<AppleMusicAlbum | nul
// Check if tracks are in the included array // Check if tracks are in the included array
if (response.included?.length) { if (response.included?.length) {
console.log('First included track:', JSON.stringify(response.included[0], null, 2)) logger.music('debug', 'First included track:', { track: response.included[0] })
} }
return response.data?.[0] || null return response.data?.[0] || null
} catch (error) { } catch (error) {
console.error(`Failed to get album details for ID ${id}:`, error) logger.error(`Failed to get album details for ID ${id}:`, error as Error, undefined, 'music')
return null return null
} }
} }
@ -158,7 +159,7 @@ export async function findAlbum(artist: string, album: string): Promise<AppleMus
// Check if this album was already marked as not found // Check if this album was already marked as not found
if (await rateLimiter.isNotFoundCached(identifier)) { if (await rateLimiter.isNotFoundCached(identifier)) {
console.log(`Album "${album}" by "${artist}" is cached as not found`) logger.music('debug', `Album "${album}" by "${artist}" is cached as not found`)
return null return null
} }
@ -176,19 +177,18 @@ export async function findAlbum(artist: string, album: string): Promise<AppleMus
const searchQuery = `${artist} ${searchAlbum}` const searchQuery = `${artist} ${searchAlbum}`
const response = await searchAlbums(searchQuery, 5, storefront) const response = await searchAlbums(searchQuery, 5, storefront)
console.log( logger.music('debug', `Search results for "${searchQuery}" in ${storefront} storefront:`, {
`Search results for "${searchQuery}" in ${storefront} storefront:`, response
JSON.stringify(response, null, 2) })
)
if (!response.results?.albums?.data?.length) { if (!response.results?.albums?.data?.length) {
console.log(`No albums found in ${storefront} storefront`) logger.music('debug', `No albums found in ${storefront} storefront`)
return null return null
} }
// Try to find the best match // Try to find the best match
const albums = response.results.albums.data const albums = response.results.albums.data
console.log(`Found ${albums.length} albums`) logger.music('debug', `Found ${albums.length} albums`)
// First try exact match with original album name // First try exact match with original album name
let match = albums.find( let match = albums.find(
@ -224,7 +224,7 @@ export async function findAlbum(artist: string, album: string): Promise<AppleMus
// If no match, try Japanese storefront // If no match, try Japanese storefront
if (!result) { if (!result) {
console.log(`No match found in US storefront, trying Japanese storefront`) logger.music('debug', `No match found in US storefront, trying Japanese storefront`)
result = await searchAndMatch(album, JAPANESE_STOREFRONT) result = await searchAndMatch(album, JAPANESE_STOREFRONT)
} }
@ -232,14 +232,14 @@ export async function findAlbum(artist: string, album: string): Promise<AppleMus
if (!result) { if (!result) {
const cleanedAlbum = removeLeadingPunctuation(album) const cleanedAlbum = removeLeadingPunctuation(album)
if (cleanedAlbum !== album && cleanedAlbum.length > 0) { if (cleanedAlbum !== album && cleanedAlbum.length > 0) {
console.log( logger.music('debug',
`No match found for "${album}", trying without leading punctuation: "${cleanedAlbum}"` `No match found for "${album}", trying without leading punctuation: "${cleanedAlbum}"`
) )
result = await searchAndMatch(cleanedAlbum) result = await searchAndMatch(cleanedAlbum)
// Also try Japanese storefront with cleaned album name // Also try Japanese storefront with cleaned album name
if (!result) { 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) result = await searchAndMatch(cleanedAlbum, JAPANESE_STOREFRONT)
} }
} }
@ -258,7 +258,7 @@ export async function findAlbum(artist: string, album: string): Promise<AppleMus
// Return the match // Return the match
return result.album return result.album
} catch (error) { } catch (error) {
console.error(`Failed to find album "${album}" by "${artist}":`, error) logger.error(`Failed to find album "${album}" by "${artist}":`, error as Error, undefined, 'music')
// Don't cache as not found on error - might be temporary // Don't cache as not found on error - might be temporary
return null return null
} }
@ -285,7 +285,7 @@ export async function transformAlbumData(appleMusicAlbum: AppleMusicAlbum) {
included?: AppleMusicTrack[] included?: AppleMusicTrack[]
}>(endpoint, `album:${appleMusicAlbum.id}`) }>(endpoint, `album:${appleMusicAlbum.id}`)
console.log(`Album details response structure:`, { logger.music('debug', `Album details response structure:`, {
hasData: !!response.data, hasData: !!response.data,
dataLength: response.data?.length, dataLength: response.data?.length,
hasIncluded: !!response.included, hasIncluded: !!response.included,
@ -300,7 +300,7 @@ export async function transformAlbumData(appleMusicAlbum: AppleMusicAlbum) {
const tracksData = albumData?.relationships?.tracks?.data const tracksData = albumData?.relationships?.tracks?.data
if (tracksData?.length) { 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 // Process all tracks
tracks = tracksData tracks = tracksData
@ -313,7 +313,7 @@ export async function transformAlbumData(appleMusicAlbum: AppleMusicAlbum) {
// Log track details // Log track details
tracks.forEach((track, index) => { tracks.forEach((track, index) => {
console.log( logger.music('debug',
`Track ${index + 1}: ${track.name} - Preview: ${track.previewUrl ? 'Yes' : 'No'} - Duration: ${track.durationMs}ms` `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) { for (const track of tracksData) {
if (track.type === 'songs' && track.attributes?.previews?.[0]?.url) { if (track.type === 'songs' && track.attributes?.previews?.[0]?.url) {
previewUrl = 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 break
} }
} }
} }
} else { } else {
console.log('No tracks found in album response') logger.music('debug', 'No tracks found in album response')
} }
} catch (error) { } catch (error) {
console.error('Failed to fetch album tracks:', error) logger.error('Failed to fetch album tracks:', error as Error, undefined, 'music')
} }
} }

View file

@ -1,6 +1,7 @@
import { dev } from '$app/environment' import { dev } from '$app/environment'
export type LogLevel = 'debug' | 'info' | 'warn' | 'error' export type LogLevel = 'debug' | 'info' | 'warn' | 'error'
export type LogCategory = 'music' | 'api' | 'db' | 'media' | 'general'
interface LogEntry { interface LogEntry {
level: LogLevel level: LogLevel
@ -8,19 +9,46 @@ interface LogEntry {
timestamp: string timestamp: string
context?: Record<string, any> context?: Record<string, any>
error?: Error error?: Error
category?: LogCategory
} }
class Logger { class Logger {
private shouldLog(level: LogLevel): boolean { private debugCategories: Set<LogCategory> = new Set()
// In development, log everything
if (dev) return true 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 // In production, only log warnings and errors
return level === 'warn' || level === 'error' return false
} }
private formatLog(entry: LogEntry): string { 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) { if (entry.context) {
parts.push(JSON.stringify(entry.context, null, 2)) parts.push(JSON.stringify(entry.context, null, 2))
@ -36,15 +64,16 @@ class Logger {
return parts.join(' ') return parts.join(' ')
} }
private log(level: LogLevel, message: string, context?: Record<string, any>, error?: Error) { private log(level: LogLevel, message: string, context?: Record<string, any>, error?: Error, category?: LogCategory) {
if (!this.shouldLog(level)) return if (!this.shouldLog(level, category)) return
const entry: LogEntry = { const entry: LogEntry = {
level, level,
message, message,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
context, context,
error error,
category
} }
const formatted = this.formatLog(entry) const formatted = this.formatLog(entry)
@ -63,20 +92,25 @@ class Logger {
} }
} }
debug(message: string, context?: Record<string, any>) { debug(message: string, context?: Record<string, any>, category?: LogCategory) {
this.log('debug', message, context) this.log('debug', message, context, undefined, category)
} }
info(message: string, context?: Record<string, any>) { info(message: string, context?: Record<string, any>, category?: LogCategory) {
this.log('info', message, context) this.log('info', message, context, undefined, category)
} }
warn(message: string, context?: Record<string, any>) { warn(message: string, context?: Record<string, any>, category?: LogCategory) {
this.log('warn', message, context) this.log('warn', message, context, undefined, category)
} }
error(message: string, error?: Error, context?: Record<string, any>) { error(message: string, error?: Error, context?: Record<string, any>, category?: LogCategory) {
this.log('error', message, context, error) this.log('error', message, context, error, category)
}
// Convenience method for music-related logs
music(level: LogLevel, message: string, context?: Record<string, any>) {
this.log(level, message, context, undefined, 'music')
} }
// Log API requests // Log API requests

View file

@ -50,6 +50,8 @@
auditData?.orphanedFiles auditData?.orphanedFiles
.filter((f) => selectedFiles.has(f.publicId)) .filter((f) => selectedFiles.has(f.publicId))
.reduce((sum, f) => sum + f.size, 0) || 0 .reduce((sum, f) => sum + f.size, 0) || 0
$: console.log('Reactive state:', { hasSelection, selectedFilesSize: selectedFiles.size, deleting, showDeleteModal, showCleanupModal })
onMount(() => { onMount(() => {
runAudit() runAudit()
@ -93,12 +95,14 @@
} }
function toggleFile(publicId: string) { function toggleFile(publicId: string) {
console.log('toggleFile called', publicId)
if (selectedFiles.has(publicId)) { if (selectedFiles.has(publicId)) {
selectedFiles.delete(publicId) selectedFiles.delete(publicId)
} else { } else {
selectedFiles.add(publicId) selectedFiles.add(publicId)
} }
selectedFiles = selectedFiles // Trigger reactivity selectedFiles = selectedFiles // Trigger reactivity
console.log('selectedFiles after toggle:', Array.from(selectedFiles))
} }
async function deleteSelected(dryRun = true) { async function deleteSelected(dryRun = true) {
@ -286,13 +290,16 @@
{/if} {/if}
</div> </div>
<div class="actions"> <div class="actions">
<Button variant="text" size="small" onclick={toggleSelectAll}> <Button variant="text" buttonSize="small" onclick={toggleSelectAll}>
{allSelected ? 'Deselect All' : 'Select All'} {allSelected ? 'Deselect All' : 'Select All'}
</Button> </Button>
<Button <Button
variant="danger" variant="danger"
size="small" buttonSize="small"
onclick={() => (showDeleteModal = true)} onclick={() => {
console.log('Delete Selected clicked', { hasSelection, deleting, selectedFiles: Array.from(selectedFiles) })
showDeleteModal = true
}}
disabled={!hasSelection || deleting} disabled={!hasSelection || deleting}
icon={Trash2} icon={Trash2}
iconPosition="left" iconPosition="left"
@ -379,8 +386,11 @@
</p> </p>
<Button <Button
variant="secondary" variant="secondary"
size="small" buttonSize="small"
onclick={() => (showCleanupModal = true)} onclick={() => {
console.log('Clean Up Broken References clicked', { cleaningUp, missingReferencesCount: auditData?.missingReferences.length })
showCleanupModal = true
}}
disabled={cleaningUp} disabled={cleaningUp}
icon={AlertCircle} icon={AlertCircle}
iconPosition="left" iconPosition="left"
@ -405,32 +415,54 @@
</AdminPage> </AdminPage>
<!-- Delete Confirmation Modal --> <!-- Delete Confirmation Modal -->
<Modal bind:open={showDeleteModal} title="Delete Orphaned Files"> <Modal bind:isOpen={showDeleteModal}>
<div class="delete-confirmation"> <div class="audit-modal-content">
<p>Are you sure you want to delete {selectedFiles.size} orphaned files?</p> <div class="modal-header">
<p class="size-info">This will free up {formatBytes(selectedSize)} of storage.</p> <h2>Delete Orphaned Files</h2>
<p class="warning">⚠️ This action cannot be undone.</p> </div>
</div> <div class="delete-confirmation">
<div slot="actions"> <p>Are you sure you want to delete {selectedFiles.size} orphaned files?</p>
<Button variant="secondary" onclick={() => (showDeleteModal = false)}>Cancel</Button> <p class="size-info">This will free up {formatBytes(selectedSize)} of storage.</p>
<Button variant="danger" onclick={() => deleteSelected(false)} disabled={deleting}> <p class="warning">⚠️ This action cannot be undone.</p>
{deleting ? 'Deleting...' : 'Delete Files'} </div>
</Button> <div class="modal-actions">
<Button variant="secondary" onclick={() => {
console.log('Cancel clicked')
showDeleteModal = false
}}>Cancel</Button>
<Button variant="danger" onclick={() => {
console.log('Delete Files clicked')
deleteSelected(false)
}} disabled={deleting}>
{deleting ? 'Deleting...' : 'Delete Files'}
</Button>
</div>
</div> </div>
</Modal> </Modal>
<!-- Cleanup Confirmation Modal --> <!-- Cleanup Confirmation Modal -->
<Modal bind:open={showCleanupModal} title="Clean Up Broken References"> <Modal bind:isOpen={showCleanupModal}>
<div class="cleanup-confirmation"> <div class="audit-modal-content">
<p>Are you sure you want to clean up {auditData?.missingReferences.length || 0} broken references?</p> <div class="modal-header">
<p class="warning">⚠️ This will remove Cloudinary URLs from database records where the files no longer exist.</p> <h2>Clean Up Broken References</h2>
<p>This action cannot be undone.</p> </div>
</div> <div class="cleanup-confirmation">
<div slot="actions"> <p>Are you sure you want to clean up {auditData?.missingReferences.length || 0} broken references?</p>
<Button variant="secondary" onclick={() => (showCleanupModal = false)}>Cancel</Button> <p class="warning">⚠️ This will remove Cloudinary URLs from database records where the files no longer exist.</p>
<Button variant="danger" onclick={cleanupBrokenReferences} disabled={cleaningUp}> <p>This action cannot be undone.</p>
{cleaningUp ? 'Cleaning Up...' : 'Clean Up References'} </div>
</Button> <div class="modal-actions">
<Button variant="secondary" onclick={() => {
console.log('Cancel cleanup clicked')
showCleanupModal = false
}}>Cancel</Button>
<Button variant="danger" onclick={() => {
console.log('Clean Up References clicked')
cleanupBrokenReferences()
}} disabled={cleaningUp}>
{cleaningUp ? 'Cleaning Up...' : 'Clean Up References'}
</Button>
</div>
</div> </div>
</Modal> </Modal>
@ -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 { @keyframes spin {
to { to {
transform: rotate(360deg); transform: rotate(360deg);

View file

@ -5,6 +5,7 @@ import type { Album, AlbumImages } from '$lib/types/lastfm'
import type { LastfmImage } from '@musicorum/lastfm/dist/types/packages/common' import type { LastfmImage } from '@musicorum/lastfm/dist/types/packages/common'
import { findAlbum, transformAlbumData } from '$lib/server/apple-music-client' import { findAlbum, transformAlbumData } from '$lib/server/apple-music-client'
import redis from '../redis-client' import redis from '../redis-client'
import { logger } from '$lib/server/logger'
const LASTFM_API_KEY = process.env.LASTFM_API_KEY const LASTFM_API_KEY = process.env.LASTFM_API_KEY
const USERNAME = 'jedmund' const USERNAME = 'jedmund'
@ -35,7 +36,7 @@ export const GET: RequestHandler = async ({ url }) => {
return await enrichAlbumWithInfo(client, album) return await enrichAlbumWithInfo(client, album)
} catch (error) { } catch (error) {
if (error instanceof Error && error.message.includes('Album not found')) { 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 return null // Skip the album
} }
throw error // Re-throw if it's a different error throw error // Re-throw if it's a different error
@ -78,7 +79,7 @@ async function getRecentAlbums(
let recentTracksResponse let recentTracksResponse
if (cached) { if (cached) {
console.log('Using cached Last.fm recent tracks') logger.music('debug', 'Using cached Last.fm recent tracks')
recentTracksResponse = JSON.parse(cached) recentTracksResponse = JSON.parse(cached)
// Convert date strings back to Date objects // Convert date strings back to Date objects
if (recentTracksResponse.tracks) { if (recentTracksResponse.tracks) {
@ -162,7 +163,7 @@ async function enrichAlbumWithInfo(client: LastClient, album: Album): Promise<Al
const cached = await redis.get(cacheKey) const cached = await redis.get(cacheKey)
if (cached) { if (cached) {
console.log(`Using cached album info for "${album.name}"`) logger.music('debug', `Using cached album info for "${album.name}"`)
const albumInfo = JSON.parse(cached) const albumInfo = JSON.parse(cached)
return { return {
...album, ...album,
@ -196,7 +197,7 @@ async function searchAppleMusicForAlbum(album: Album): Promise<Album> {
if (cached) { if (cached) {
const cachedData = JSON.parse(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, hasPreview: !!cachedData.previewUrl,
trackCount: cachedData.tracks?.length || 0 trackCount: cachedData.tracks?.length || 0
}) })

View file

@ -4,6 +4,7 @@ import type { Album, AlbumImages } from '$lib/types/lastfm'
import type { LastfmImage } from '@musicorum/lastfm/dist/types/packages/common' import type { LastfmImage } from '@musicorum/lastfm/dist/types/packages/common'
import { findAlbum, transformAlbumData } from '$lib/server/apple-music-client' import { findAlbum, transformAlbumData } from '$lib/server/apple-music-client'
import redis from '../../redis-client' import redis from '../../redis-client'
import { logger } from '$lib/server/logger'
const LASTFM_API_KEY = process.env.LASTFM_API_KEY const LASTFM_API_KEY = process.env.LASTFM_API_KEY
const USERNAME = 'jedmund' const USERNAME = 'jedmund'
@ -41,7 +42,7 @@ export const GET: RequestHandler = async ({ request }) => {
try { try {
controller.enqueue(encoder.encode('event: connected\ndata: {}\n\n')) controller.enqueue(encoder.encode('event: connected\ndata: {}\n\n'))
} catch (e) { } catch (e) {
console.error('Failed to send initial message:', e) logger.error('Failed to send initial message:', e as Error, undefined, 'music')
return return
} }
@ -81,7 +82,7 @@ export const GET: RequestHandler = async ({ request }) => {
return withAppleMusic return withAppleMusic
} catch (error) { } catch (error) {
console.error(`Error enriching album ${album.name}:`, error) logger.error(`Error enriching album ${album.name}:`, error as Error, undefined, 'music')
return album 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 // Ensure only one album is marked as now playing in the enriched albums
const nowPlayingCount = enrichedAlbums.filter((a) => a.isNowPlaying).length const nowPlayingCount = enrichedAlbums.filter((a) => a.isNowPlaying).length
if (nowPlayingCount > 1) { if (nowPlayingCount > 1) {
console.log( logger.music('debug',
`Multiple enriched albums marked as now playing (${nowPlayingCount}), keeping only the most recent one` `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) => { enrichedAlbums.forEach((album, index) => {
if (album.isNowPlaying) { if (album.isNowPlaying) {
if (foundFirst) { 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.isNowPlaying = false
album.nowPlayingTrack = undefined album.nowPlayingTrack = undefined
} else { } 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 foundFirst = true
} }
} }
@ -148,7 +149,7 @@ export const GET: RequestHandler = async ({ request }) => {
const data = JSON.stringify(enrichedAlbums) const data = JSON.stringify(enrichedAlbums)
controller.enqueue(encoder.encode(`event: albums\ndata: ${data}\n\n`)) controller.enqueue(encoder.encode(`event: albums\ndata: ${data}\n\n`))
const nowPlayingAlbum = enrichedAlbums.find((a) => a.isNowPlaying) 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, totalAlbums: enrichedAlbums.length,
nowPlayingAlbum: nowPlayingAlbum nowPlayingAlbum: nowPlayingAlbum
? `${nowPlayingAlbum.artist.name} - ${nowPlayingAlbum.name}` ? `${nowPlayingAlbum.artist.name} - ${nowPlayingAlbum.name}`
@ -183,7 +184,7 @@ export const GET: RequestHandler = async ({ request }) => {
(album.isNowPlaying && album.nowPlayingTrack !== lastTrack) (album.isNowPlaying && album.nowPlayingTrack !== lastTrack)
) { ) {
updates.push(album) updates.push(album)
console.log( logger.music('debug',
`Now playing update for non-recent album ${album.albumName}: playing=${album.isNowPlaying}, track=${album.nowPlayingTrack}` `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) { } 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() { cancel() {
// Cleanup when stream is cancelled // 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<NowPlayingUpdate
let recentTracksResponse let recentTracksResponse
if (cached) { if (cached) {
console.log('Using cached Last.fm recent tracks for streaming') logger.music('debug', 'Using cached Last.fm recent tracks for streaming')
recentTracksResponse = JSON.parse(cached) recentTracksResponse = JSON.parse(cached)
// Convert date strings back to Date objects // Convert date strings back to Date objects
if (recentTracksResponse.tracks) { if (recentTracksResponse.tracks) {
@ -274,7 +275,7 @@ async function getNowPlayingAlbums(client: LastClient): Promise<NowPlayingUpdate
})) }))
} }
} else { } else {
console.log('Fetching fresh Last.fm recent tracks for streaming') logger.music('debug', 'Fetching fresh Last.fm recent tracks for streaming')
recentTracksResponse = await client.user.getRecentTracks(USERNAME, { recentTracksResponse = await client.user.getRecentTracks(USERNAME, {
limit: 50, limit: 50,
extended: true extended: true
@ -333,7 +334,7 @@ async function getNowPlayingAlbums(client: LastClient): Promise<NowPlayingUpdate
// Ensure only one album is marked as now playing - keep the most recent one // Ensure only one album is marked as now playing - keep the most recent one
const nowPlayingAlbums = Array.from(albums.values()).filter((a) => a.isNowPlaying) const nowPlayingAlbums = Array.from(albums.values()).filter((a) => a.isNowPlaying)
if (nowPlayingAlbums.length > 1) { if (nowPlayingAlbums.length > 1) {
console.log( logger.music('debug',
`Multiple albums marked as now playing (${nowPlayingAlbums.length}), keeping only the most recent one` `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) const appleMusicData = JSON.parse(cached)
return checkWithTracks(albumName, appleMusicData.tracks) return checkWithTracks(albumName, appleMusicData.tracks)
} catch (error) { } catch (error) {
console.error(`Error checking duration for ${albumName}:`, error) logger.error(`Error checking duration for ${albumName}:`, error as Error, undefined, 'music')
return null return null
} }
} }
@ -435,7 +436,7 @@ function checkWithTracks(
) )
if (now < trackEndTime) { if (now < trackEndTime) {
console.log( logger.music('debug',
`Track "${mostRecentTrack.trackName}" is still playing (ends at ${trackEndTime.toLocaleTimeString()})` `Track "${mostRecentTrack.trackName}" is still playing (ends at ${trackEndTime.toLocaleTimeString()})`
) )
return { return {
@ -476,7 +477,7 @@ async function enrichAlbumWithInfo(client: LastClient, album: Album): Promise<Al
const cached = await redis.get(cacheKey) const cached = await redis.get(cacheKey)
if (cached) { if (cached) {
console.log(`Using cached album info for "${album.name}"`) logger.music('debug', `Using cached album info for "${album.name}"`)
const albumInfo = JSON.parse(cached) const albumInfo = JSON.parse(cached)
return { return {
...album, ...album,
@ -485,7 +486,7 @@ async function enrichAlbumWithInfo(client: LastClient, album: Album): Promise<Al
} }
} }
console.log(`Fetching fresh album info for "${album.name}"`) logger.music('debug', `Fetching fresh album info for "${album.name}"`)
const albumInfo = await client.album.getInfo(album.name, album.artist.name) const albumInfo = await client.album.getInfo(album.name, album.artist.name)
// Cache for 1 hour - album info rarely changes // Cache for 1 hour - album info rarely changes
@ -535,9 +536,11 @@ async function searchAppleMusicForAlbum(album: Album): Promise<Album> {
} }
} }
} catch (error) { } catch (error) {
console.error( logger.error(
`Failed to fetch Apple Music data for "${album.name}" by "${album.artist.name}":`, `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<A
let recentTracksResponse let recentTracksResponse
if (cached) { if (cached) {
console.log('Using cached Last.fm recent tracks for album stream') logger.music('debug', 'Using cached Last.fm recent tracks for album stream')
recentTracksResponse = JSON.parse(cached) recentTracksResponse = JSON.parse(cached)
// Convert date strings back to Date objects // Convert date strings back to Date objects
if (recentTracksResponse.tracks) { if (recentTracksResponse.tracks) {
@ -562,7 +565,7 @@ async function getRecentAlbums(client: LastClient, limit: number = 4): Promise<A
})) }))
} }
} else { } else {
console.log('Fetching fresh Last.fm recent tracks for album stream') logger.music('debug', 'Fetching fresh Last.fm recent tracks for album stream')
recentTracksResponse = await client.user.getRecentTracks(USERNAME, { recentTracksResponse = await client.user.getRecentTracks(USERNAME, {
limit: 50, limit: 50,
extended: true extended: true