Merge pull request #11 from jedmund/jedmund/music-refactor

Refactor a bunch of music code to make it more reliable
This commit is contained in:
Justin Edmund 2025-07-10 21:43:52 -07:00 committed by GitHub
commit c90c2a9bdd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 2619 additions and 173 deletions

1
.gitignore vendored
View file

@ -32,3 +32,4 @@ vite.config.ts.timestamp-*
*storybook.log
storybook-static
backups/
server.log

View file

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.293 4.293a1 1 0 0 1 0 1.414l-8 8a1 1 0 0 1-1.414 0l-3.5-3.5a1 1 0 0 1 1.414-1.414L6 12l7.293-7.293a1 1 0 0 1 1.414 0z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 258 B

View file

@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 14A6 6 0 1 0 8 2a6 6 0 0 0 0 12z" stroke="currentColor" stroke-width="1.5"/>
<path d="M8 4v4l2.5 2.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 286 B

View file

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 2v2m0 8v2M4 8H2m12 0h-2m-1.172-4.828L9.414 4.586M6.586 11.414l-1.414 1.414m0-9.656l1.414 1.414m7.242 7.242l-1.414-1.414" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 303 B

View file

@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 10a2 2 0 1 0 0-4 2 2 0 0 0 0 4z" stroke="currentColor" stroke-width="1.5"/>
<path d="M12.933 10a1.066 1.066 0 0 0 .213 1.173l.04.04a1.294 1.294 0 1 1-1.833 1.833l-.04-.04a1.067 1.067 0 0 0-1.813.76v.113a1.293 1.293 0 1 1-2.587 0v-.06a1.067 1.067 0 0 0-.7-1.013 1.067 1.067 0 0 0-1.173.213l-.04.04a1.294 1.294 0 1 1-1.833-1.833l.04-.04a1.067 1.067 0 0 0-.76-1.813h-.114a1.293 1.293 0 0 1 0-2.587h.06a1.067 1.067 0 0 0 1.013-.7 1.066 1.066 0 0 0-.213-1.173l-.04-.04A1.293 1.293 0 1 1 4.953 2.86l.04.04a1.067 1.067 0 0 0 1.813-.76v-.113a1.293 1.293 0 0 1 2.587 0v.06a1.067 1.067 0 0 0 1.873.913l.04-.04a1.294 1.294 0 1 1 1.833 1.833l-.04.04a1.066 1.066 0 0 0 .76 1.813h.114a1.293 1.293 0 0 1 0 2.587h-.06a1.067 1.067 0 0 0-.913.873l-.02.094z" stroke="currentColor" stroke-width="1.5"/>
</svg>

After

Width:  |  Height:  |  Size: 901 B

View file

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 4h12M5.333 4V2.667A1.333 1.333 0 0 1 6.667 1.333h2.666A1.333 1.333 0 0 1 10.667 2.667V4m2 0v9.333A1.333 1.333 0 0 1 11.333 14.667H4.667A1.333 1.333 0 0 1 3.333 13.333V4h9.334zM6.667 7.333v4M9.333 7.333v4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 411 B

3
src/assets/icons/x.svg Normal file
View file

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 4L4 12M4 4l8 8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 221 B

View file

@ -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}
<svelte:component this={PauseIcon} />
<PauseIcon />
{:else}
<svelte:component this={PlayIcon} />
<PlayIcon />
{/if}
</button>
{/if}

View file

@ -0,0 +1,441 @@
<script lang="ts">
import { onMount } from 'svelte'
import XIcon from '$icons/x.svg'
import LoaderIcon from '$icons/loader.svg'
let isOpen = $state(false)
let searchQuery = $state('')
let storefront = $state('us')
let isSearching = $state(false)
let searchResults = $state<any>(null)
let searchError = $state<string | null>(null)
let responseTime = $state<number>(0)
// Available storefronts
const storefronts = [
{ value: 'us', label: 'United States' },
{ value: 'jp', label: 'Japan' },
{ value: 'gb', label: 'United Kingdom' },
{ value: 'ca', label: 'Canada' },
{ value: 'au', label: 'Australia' },
{ value: 'de', label: 'Germany' },
{ value: 'fr', label: 'France' },
{ value: 'es', label: 'Spain' },
{ value: 'it', label: 'Italy' },
{ value: 'kr', label: 'South Korea' },
{ value: 'cn', label: 'China' },
{ value: 'br', label: 'Brazil' }
]
export function open() {
isOpen = true
searchQuery = ''
searchResults = null
searchError = null
responseTime = 0
}
function close() {
isOpen = false
}
async function performSearch() {
if (!searchQuery.trim()) {
searchError = 'Please enter a search query'
return
}
isSearching = true
searchError = null
searchResults = null
const startTime = performance.now()
try {
const response = await fetch('/api/admin/debug/apple-music-search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: searchQuery,
storefront
})
})
responseTime = Math.round(performance.now() - startTime)
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
searchResults = await response.json()
} catch (error) {
searchError = error instanceof Error ? error.message : 'Unknown error occurred'
searchResults = null
} finally {
isSearching = false
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && isOpen) {
close()
} else if (e.key === 'Enter' && !isSearching) {
performSearch()
}
}
onMount(() => {
window.addEventListener('keydown', handleKeydown)
return () => window.removeEventListener('keydown', handleKeydown)
})
</script>
{#if isOpen}
<div class="modal-overlay" onclick={close}>
<div class="modal-container" onclick={(e) => e.stopPropagation()}>
<div class="modal-header">
<h2>Apple Music API Search</h2>
<button class="close-btn" onclick={close} aria-label="Close">
<XIcon />
</button>
</div>
<div class="modal-body">
<div class="search-controls">
<div class="control-group">
<label for="search-query">Search Query</label>
<input
id="search-query"
type="text"
bind:value={searchQuery}
placeholder="e.g., Taylor Swift folklore"
disabled={isSearching}
/>
</div>
<div class="control-group">
<label for="storefront">Storefront</label>
<select id="storefront" bind:value={storefront} disabled={isSearching}>
{#each storefronts as store}
<option value={store.value}>{store.label}</option>
{/each}
</select>
</div>
<button
class="search-btn"
onclick={performSearch}
disabled={isSearching || !searchQuery.trim()}
>
{#if isSearching}
<LoaderIcon class="icon spinning" /> Searching...
{:else}
Search
{/if}
</button>
</div>
{#if searchError}
<div class="error-message">
<strong>Error:</strong> {searchError}
</div>
{/if}
{#if responseTime > 0}
<div class="response-time">
Response time: {responseTime}ms
</div>
{/if}
{#if searchResults}
<div class="results-section">
<h3>Results</h3>
<div class="result-tabs">
<button
class="tab"
class:active={true}
onclick={() => {}}
>
Raw JSON
</button>
<button
class="copy-btn"
onclick={async () => {
try {
await navigator.clipboard.writeText(JSON.stringify(searchResults, null, 2))
// Show a temporary success message
const btn = event?.target as HTMLButtonElement
if (btn) {
const originalText = btn.textContent
btn.textContent = 'Copied!'
setTimeout(() => {
btn.textContent = originalText
}, 2000)
}
} catch (err) {
console.error('Failed to copy:', err)
}
}}
>
Copy to Clipboard
</button>
</div>
<div class="results-content">
<pre>{JSON.stringify(searchResults, null, 2)}</pre>
</div>
</div>
{/if}
</div>
</div>
</div>
{/if}
<style lang="scss">
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(4px);
}
.modal-container {
background: rgba(20, 20, 20, 0.98);
border-radius: $unit * 1.5;
width: 90%;
max-width: 800px;
max-height: 90vh;
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.8);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: $unit * 2;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
h2 {
margin: 0;
color: white;
font-size: 18px;
font-weight: 600;
}
.close-btn {
background: none;
border: none;
color: rgba(255, 255, 255, 0.6);
cursor: pointer;
padding: $unit-half;
border-radius: 4px;
transition: all 0.2s;
:global(svg) {
width: 20px;
height: 20px;
}
&:hover {
color: white;
background: rgba(255, 255, 255, 0.1);
}
}
}
.modal-body {
flex: 1;
overflow-y: auto;
padding: $unit * 2;
}
.search-controls {
display: flex;
gap: $unit * 2;
margin-bottom: $unit * 2;
align-items: flex-end;
.control-group {
flex: 1;
label {
display: block;
color: rgba(255, 255, 255, 0.8);
font-size: 12px;
font-weight: 500;
margin-bottom: $unit-half;
}
input, select {
width: 100%;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
color: white;
padding: $unit;
border-radius: 4px;
font-size: 14px;
font-family: inherit;
&::placeholder {
color: rgba(255, 255, 255, 0.4);
}
&:focus {
outline: none;
border-color: $primary-color;
background: rgba(255, 255, 255, 0.15);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}
.search-btn {
padding: $unit $unit * 2;
background: $primary-color;
border: none;
color: white;
border-radius: 4px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: $unit-half;
white-space: nowrap;
&:hover:not(:disabled) {
background: darken($primary-color, 10%);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
:global(.icon) {
width: 16px;
height: 16px;
}
}
}
.error-message {
background: rgba(255, 59, 48, 0.1);
border: 1px solid rgba(255, 59, 48, 0.3);
color: #ff6b6b;
padding: $unit;
border-radius: 4px;
font-size: 13px;
margin-bottom: $unit * 2;
}
.response-time {
color: rgba(255, 255, 255, 0.6);
font-size: 12px;
margin-bottom: $unit;
}
.results-section {
margin-top: $unit * 2;
h3 {
margin: 0 0 $unit 0;
color: #87ceeb;
font-size: 16px;
font-weight: 600;
}
}
.result-tabs {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
margin-bottom: $unit * 2;
.tab {
padding: $unit $unit * 2;
background: none;
border: none;
color: rgba(255, 255, 255, 0.6);
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: all 0.2s;
border-bottom: 2px solid transparent;
&:hover {
color: rgba(255, 255, 255, 0.8);
}
&.active {
color: white;
border-bottom-color: $primary-color;
}
}
.copy-btn {
padding: $unit-half $unit;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
color: rgba(255, 255, 255, 0.8);
border-radius: 4px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: rgba(255, 255, 255, 0.15);
border-color: rgba(255, 255, 255, 0.3);
color: white;
}
}
}
.results-content {
background: rgba(0, 0, 0, 0.5);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
max-height: 400px;
overflow-y: auto;
pre {
margin: 0;
padding: $unit * 1.5;
font-size: 12px;
line-height: 1.5;
color: rgba(255, 255, 255, 0.9);
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
}
}
:global(.spinning) {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>

View file

@ -1,11 +1,9 @@
<script>
import { onMount, onDestroy } from 'svelte'
import { Spring } from 'svelte/motion'
import { nowPlayingStream } from '$lib/stores/now-playing-stream'
import { albumStream } from '$lib/stores/album-stream'
import { musicStream } from '$lib/stores/music-stream'
import AvatarSVG from './AvatarSVG.svelte'
import AvatarHeadphones from './AvatarHeadphones.svelte'
import { get } from 'svelte/store'
// Props for testing/forcing states
let { forcePlayingMusic = false } = $props()
@ -14,9 +12,6 @@
let isBlinking = $state(false)
let isPlayingMusic = $state(forcePlayingMusic)
// Track store subscriptions for debugging
let nowPlayingStoreState = $state(null)
let albumStoreState = $state(null)
const scale = new Spring(1, {
stiffness: 0.1,
@ -66,56 +61,25 @@
}
}, 4000)
// Subscribe to now playing updates from both sources
const unsubscribeNowPlaying = nowPlayingStream.subscribe((state) => {
nowPlayingStoreState = state
// Check if any album is currently playing, unless forced
// Subscribe to music stream - single source of truth
const unsubscribe = musicStream.nowPlaying.subscribe((nowPlaying) => {
if (!forcePlayingMusic) {
const nowPlayingFromStream = Array.from(state.updates.values()).some(
(update) => update.isNowPlaying
)
console.log('Avatar - nowPlayingStream update:', {
updatesCount: state.updates.size,
hasNowPlaying: nowPlayingFromStream
})
// Don't set to false if we haven't received album data yet
if (nowPlayingFromStream || albumStoreState !== null) {
isPlayingMusic =
nowPlayingFromStream || (albumStoreState?.some((album) => album.isNowPlaying) ?? false)
isPlayingMusic = !!nowPlaying
if (nowPlaying) {
console.log('Avatar - music playing:', {
artist: nowPlaying.album.artist.name,
album: nowPlaying.album.name,
track: nowPlaying.track
})
}
}
})
// Also check the album stream
const unsubscribeAlbums = albumStream.subscribe((state) => {
albumStoreState = state.albums
if (!forcePlayingMusic) {
const hasNowPlaying = state.albums.some((album) => album.isNowPlaying)
// Get the current state of nowPlayingStream
const nowPlayingState = nowPlayingStoreState || get(nowPlayingStream)
const nowPlayingFromStream = Array.from(nowPlayingState.updates.values()).some(
(update) => update.isNowPlaying
)
console.log('Avatar - albumStream update:', {
albumsCount: state.albums.length,
hasNowPlayingInAlbums: hasNowPlaying,
hasNowPlayingInStream: nowPlayingFromStream,
albums: state.albums.map((a) => ({ name: a.name, isNowPlaying: a.isNowPlaying }))
})
// Update isPlayingMusic based on whether any album is now playing from either source
isPlayingMusic = hasNowPlaying || nowPlayingFromStream
}
})
return () => {
if (blinkInterval) {
clearInterval(blinkInterval)
}
unsubscribeNowPlaying()
unsubscribeAlbums()
unsubscribe()
}
})
</script>

File diff suppressed because it is too large Load diff

View file

@ -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<Album | null>(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

View file

@ -1,7 +1,7 @@
<script lang="ts">
import Album from '$components/Album.svelte'
import type { Album as AlbumType } from '$lib/types/lastfm'
import { albumStream } from '$lib/stores/album-stream'
import { musicStream } from '$lib/stores/music-stream'
interface RecentAlbumsProps {
albums?: AlbumType[]
@ -13,7 +13,7 @@
let albums = $state<AlbumType[]>(initialAlbums)
$effect(() => {
const unsubscribe = albumStream.albums.subscribe((streamAlbums) => {
const unsubscribe = musicStream.albums.subscribe((streamAlbums) => {
if (streamAlbums.length > 0) {
albums = streamAlbums
}

View file

@ -1,10 +1,10 @@
<script lang="ts">
import { nowPlayingStream } from '$lib/stores/now-playing-stream'
import { musicStream } from '$lib/stores/music-stream'
let isConnected = $state(false)
$effect(() => {
const unsubscribe = nowPlayingStream.subscribe((state) => {
const unsubscribe = musicStream.subscribe((state) => {
isConnected = state.connected
})
return unsubscribe

View file

@ -104,6 +104,18 @@ export async function searchAlbums(
return makeAppleMusicRequest<AppleMusicSearchResponse>(endpoint, query)
}
// Search for both albums and songs
export async function searchAlbumsAndSongs(
query: string,
limit: number = 10,
storefront: string = DEFAULT_STOREFRONT
): Promise<AppleMusicSearchResponse> {
const encodedQuery = encodeURIComponent(query)
const endpoint = `/catalog/${storefront}/search?types=albums,songs&term=${encodedQuery}&limit=${limit}`
return makeAppleMusicRequest<AppleMusicSearchResponse>(endpoint, query)
}
export async function searchTracks(
query: string,
limit: number = 10
@ -154,6 +166,8 @@ function containsJapanese(str: string): boolean {
// Helper function to search for an album by artist and album name
export async function findAlbum(artist: string, album: string): Promise<AppleMusicAlbum | null> {
const identifier = `${artist}:${album}`
logger.music('info', `=== SEARCHING FOR ALBUM: "${album}" by "${artist}" ===`)
// Check if this album was already marked as not found
if (await rateLimiter.isNotFoundCached(identifier)) {
@ -175,7 +189,9 @@ export async function findAlbum(artist: string, album: string): Promise<AppleMus
logger.music('debug', `Album search strategy for "${album}" by "${artist}":`, {
hasJapaneseContent,
primaryStorefront,
secondaryStorefront
secondaryStorefront,
albumHasJapanese: containsJapanese(album),
artistHasJapanese: containsJapanese(artist)
})
// Helper function to perform the album search and matching
@ -203,7 +219,11 @@ export async function findAlbum(artist: string, album: string): Promise<AppleMus
albums.forEach((a, index) => {
logger.music(
'debug',
`Album ${index + 1}: "${a.attributes?.name}" by "${a.attributes?.artistName}"`
`Album ${index + 1}: "${a.attributes?.name}" by "${a.attributes?.artistName}"`,
{
id: a.id,
hasPreview: !!a.attributes?.previews?.[0]?.url
}
)
})
@ -304,6 +324,104 @@ export async function findAlbum(artist: string, album: string): Promise<AppleMus
}
}
// If no album match found, try searching for it as a single/song
logger.music('debug', `No album found for "${album}" by "${artist}", trying as single/song`)
for (const storefront of [primaryStorefront, secondaryStorefront]) {
try {
const searchQuery = `${artist} ${album}`
logger.music('debug', `Searching for songs with query: "${searchQuery}" in ${storefront}`)
const response = await searchAlbumsAndSongs(searchQuery, 5, storefront)
// Check if we found the song
if (response.results?.songs?.data?.length) {
const songs = response.results.songs.data
logger.music('debug', `Found ${songs.length} songs in ${storefront}`)
// Log all songs for debugging
songs.forEach((s, index) => {
logger.music('debug', `Song ${index + 1}: "${s.attributes?.name}" by "${s.attributes?.artistName}" on "${s.attributes?.albumName}"`)
})
// Find matching song
const matchingSong = songs.find(s => {
const songName = s.attributes?.name || ''
const artistName = s.attributes?.artistName || ''
const albumName = s.attributes?.albumName || ''
// For single/track searches, the "album" parameter from Last.fm might actually be the track name
// Check if this is our song by comparing against the track name
const songNameLower = songName.toLowerCase()
const albumSearchLower = album.toLowerCase()
const artistNameLower = artistName.toLowerCase()
const artistSearchLower = artist.toLowerCase()
// Check if the song name matches what we're looking for
const songMatches = songNameLower === albumSearchLower ||
songNameLower.includes(albumSearchLower) ||
albumSearchLower.includes(songNameLower)
// Check if the artist matches (handle spaces in Japanese names)
const artistNameNormalized = artistNameLower.replace(/\s+/g, '')
const artistSearchNormalized = artistSearchLower.replace(/\s+/g, '')
const artistMatches = artistNameLower === artistSearchLower ||
artistNameNormalized === artistSearchNormalized ||
artistNameLower.includes(artistSearchLower) ||
artistSearchLower.includes(artistNameLower) ||
artistNameNormalized.includes(artistSearchNormalized) ||
artistSearchNormalized.includes(artistNameNormalized)
if (songMatches && artistMatches) {
logger.music('debug', `Found matching song: "${songName}" by "${artistName}" on album "${albumName}"`)
return true
}
return false
})
if (matchingSong) {
// Get the album info from the song
const albumName = matchingSong.attributes?.albumName
if (albumName) {
logger.music('debug', `Found as single/song, searching for album: "${albumName}"`)
// Search for the actual album
const albumResponse = await searchAlbums(`${artist} ${albumName}`, 5, storefront)
if (albumResponse.results?.albums?.data?.length) {
const album = albumResponse.results.albums.data[0]
const matchedAlbum = album as any
matchedAlbum._storefront = storefront
return album
}
}
// If no album found, create a synthetic album from the song
logger.music('debug', `Creating synthetic album from single: "${matchingSong.attributes?.name}"`)
return {
id: `single-${matchingSong.id}`,
type: 'albums' as const,
attributes: {
name: matchingSong.attributes?.albumName || matchingSong.attributes?.name || album,
artistName: matchingSong.attributes?.artistName || artist,
artwork: matchingSong.attributes?.artwork,
genreNames: matchingSong.attributes?.genreNames,
releaseDate: matchingSong.attributes?.releaseDate,
trackCount: 1,
isSingle: true,
// Store the song ID so we can fetch it later
_singleSongId: matchingSong.id,
_singleSongPreview: matchingSong.attributes?.previews?.[0]?.url
},
_storefront: storefront
} as any
}
}
} catch (error) {
logger.error(`Failed to search for single "${album}":`, error as Error, undefined, 'music')
}
}
// If still no match, cache as not found
await rateLimiter.cacheNotFound(identifier, 3600)
return null
@ -327,8 +445,18 @@ export async function transformAlbumData(appleMusicAlbum: AppleMusicAlbum) {
let previewUrl = attributes.previews?.[0]?.url
let tracks: Array<{ name: string; previewUrl?: string; durationMs?: number }> = []
// Check if this is a synthetic single album
if ((attributes as any).isSingle && (attributes as any)._singleSongPreview) {
logger.music('debug', 'Processing synthetic single album')
previewUrl = (attributes as any)._singleSongPreview
tracks = [{
name: attributes.name,
previewUrl: (attributes as any)._singleSongPreview,
durationMs: undefined // We'd need to fetch the song details for duration
}]
}
// Always fetch tracks to get preview URLs
if (appleMusicAlbum.id) {
else if (appleMusicAlbum.id) {
try {
// Determine which storefront to use
const storefront = (appleMusicAlbum as any)._storefront || DEFAULT_STOREFRONT
@ -350,11 +478,13 @@ export async function transformAlbumData(appleMusicAlbum: AppleMusicAlbum) {
// Process all tracks
tracks = tracksData
.filter((item: any) => item.type === 'songs')
.map((track: any) => ({
name: track.attributes?.name || 'Unknown',
previewUrl: track.attributes?.previews?.[0]?.url,
durationMs: track.attributes?.durationInMillis
}))
.map((track: any) => {
return {
name: track.attributes?.name || 'Unknown',
previewUrl: track.attributes?.previews?.[0]?.url,
durationMs: track.attributes?.durationInMillis
}
})
// Find the first track with a preview if we don't have one
if (!previewUrl) {

View file

@ -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<string, CacheConfig> = 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<string | null> {
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<void> {
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<boolean> {
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<number> {
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<number> {
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<number> {
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<Array<{ type: string; count: number; description: string }>> {
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)
}
}

View file

@ -0,0 +1,156 @@
import { writable, derived, get, type Readable } from 'svelte/store'
import { browser } from '$app/environment'
import type { Album } from '$lib/types/lastfm'
interface MusicStreamState {
connected: boolean
albums: Album[]
lastUpdate: Date | null
}
function createMusicStream() {
const { subscribe, set, update } = writable<MusicStreamState>({
connected: false,
albums: [],
lastUpdate: null
})
let eventSource: EventSource | null = null
let reconnectTimeout: NodeJS.Timeout | null = null
let reconnectAttempts = 0
function connect() {
if (!browser || eventSource?.readyState === EventSource.OPEN) return
// Don't connect in Storybook or admin
if (typeof window !== 'undefined' && window.parent !== window) {
console.log('Music stream disabled in Storybook')
return
}
// Clean up existing connection
disconnect()
eventSource = new EventSource('/api/lastfm/stream')
eventSource.addEventListener('connected', () => {
console.log('Music stream connected')
reconnectAttempts = 0
update((state) => ({ ...state, connected: true }))
})
eventSource.addEventListener('albums', (event) => {
try {
const albums: Album[] = JSON.parse(event.data)
const nowPlayingAlbum = albums.find((a) => a.isNowPlaying)
const updateTime = new Date()
console.log('🎵 Music stream update at', updateTime.toLocaleTimeString(), {
totalAlbums: albums.length,
nowPlaying: nowPlayingAlbum
? `${nowPlayingAlbum.artist.name} - ${nowPlayingAlbum.name}`
: 'none',
albums: albums.map(a => ({
name: a.name,
artist: a.artist.name,
isNowPlaying: a.isNowPlaying,
nowPlayingTrack: a.nowPlayingTrack
}))
})
update((state) => ({
...state,
albums,
lastUpdate: updateTime
}))
} catch (error) {
console.error('Error parsing albums:', error)
}
})
eventSource.addEventListener('heartbeat', (event) => {
try {
const data = JSON.parse(event.data)
console.log('💓 Heartbeat at', new Date(data.timestamp).toLocaleTimeString(), {
interval: data.interval,
hasUpdates: data.hasUpdates
})
// Update lastUpdate time even on heartbeat to keep countdown in sync
update((state) => ({
...state,
lastUpdate: new Date(data.timestamp)
}))
} catch (error) {
// Old heartbeat format, ignore
}
})
eventSource.addEventListener('error', (error) => {
console.error('Music stream error:', error)
update((state) => ({ ...state, connected: false }))
// Reconnect with exponential backoff
if (reconnectAttempts < 5) {
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000)
reconnectTimeout = setTimeout(() => {
reconnectAttempts++
connect()
}, delay)
}
})
eventSource.addEventListener('open', () => {
update((state) => ({ ...state, connected: true }))
})
}
function disconnect() {
if (eventSource) {
eventSource.close()
eventSource = null
}
if (reconnectTimeout) {
clearTimeout(reconnectTimeout)
reconnectTimeout = null
}
update((state) => ({ ...state, connected: false }))
}
// Auto-connect in browser
if (browser && !window.location.pathname.startsWith('/admin')) {
connect()
// Reconnect on visibility change
document.addEventListener('visibilitychange', () => {
const currentState = get({ subscribe })
if (
document.visibilityState === 'visible' &&
!currentState.connected &&
!window.location.pathname.startsWith('/admin')
) {
connect()
}
})
}
return {
subscribe,
connect,
disconnect,
// Derived store for albums
albums: derived({ subscribe }, ($state) => $state.albums) as Readable<Album[]>,
// Helper to check if any album is playing
nowPlaying: derived({ subscribe }, ($state) => {
const playing = $state.albums.find(a => a.isNowPlaying)
return playing ? {
album: playing,
track: playing.nowPlayingTrack
} : null
}) as Readable<{ album: Album; track?: string } | null>
}
}
export const musicStream = createMusicStream()

View file

@ -28,6 +28,7 @@ export interface Album {
images: AlbumImages
isNowPlaying?: boolean
nowPlayingTrack?: string
lastScrobbleTime?: Date | string
appleMusicData?: {
appleMusicId?: string
highResArtwork?: string
@ -44,6 +45,24 @@ export interface Album {
previewUrl?: string
durationMs?: number
}>
// Debug information
debug?: {
searchQuery?: string
storefront?: string
responseTime?: number
rawResponse?: any
matchType?: 'exact' | 'fuzzy' | 'single'
searchAttempts?: number
}
// Search metadata for failed searches
searchMetadata?: {
searchTime: string
searchQuery: string
artist: string
album: string
found: boolean
error: string | null
}
}
}

View file

@ -56,7 +56,8 @@ export function trackToAlbum(track: any, rank: number): Album {
url: track.url,
rank,
isNowPlaying: track.nowPlaying || false,
nowPlayingTrack: track.nowPlaying ? track.name : undefined
nowPlayingTrack: track.nowPlaying ? track.name : undefined,
lastScrobbleTime: track.date || track.nowPlaying ? new Date() : undefined
}
}

View file

@ -0,0 +1,107 @@
import type { Album } from '$lib/types/lastfm'
import type { LastClient } from '@musicorum/lastfm'
import { SimpleNowPlayingDetector } from './simpleNowPlayingDetector'
import { AlbumEnricher } from './albumEnricher'
import { trackToAlbum } from './lastfmTransformers'
import { logger } from '$lib/server/logger'
export interface StreamUpdate {
albums?: Album[]
}
export class SimpleLastfmStreamManager {
private client: LastClient
private username: string
private detector: SimpleNowPlayingDetector
private albumEnricher: AlbumEnricher
private lastAlbumState: string = ''
constructor(client: LastClient, username: string) {
this.client = client
this.username = username
this.detector = new SimpleNowPlayingDetector()
this.albumEnricher = new AlbumEnricher(client)
}
/**
* Check for updates and return any changes
*/
async checkForUpdates(): Promise<StreamUpdate> {
try {
// Fetch fresh data from Last.fm
logger.music('debug', '🔄 Fetching fresh tracks from Last.fm...')
const recentTracksResponse = await this.client.user.getRecentTracks(this.username, {
limit: 50,
extended: true
})
logger.music('debug', `📊 Got ${recentTracksResponse.tracks?.length || 0} tracks from Last.fm`)
// Cache for other uses but always use fresh for now playing
await this.albumEnricher.cacheRecentTracks(this.username, recentTracksResponse)
// Get recent albums (top 4)
const albums = await this.getRecentAlbums(4, recentTracksResponse)
// Debug the response structure
logger.music('debug', `📊 Response structure check:`, {
hasTracksProp: !!recentTracksResponse.tracks,
trackCount: recentTracksResponse.tracks?.length || 0,
firstTrack: recentTracksResponse.tracks?.[0]
})
// Process now playing status
const albumsWithNowPlaying = await this.detector.processAlbums(
albums,
recentTracksResponse.tracks,
(artistName, albumName) =>
this.albumEnricher.getAppleMusicDataForNowPlaying(artistName, albumName)
)
// Enrich albums with additional data
const enrichedAlbums = await Promise.all(
albumsWithNowPlaying.map((album) => this.albumEnricher.enrichAlbum(album))
)
// Check if anything changed
const currentState = JSON.stringify(
enrichedAlbums.map(a => ({
key: `${a.artist.name}:${a.name}`,
isNowPlaying: a.isNowPlaying,
track: a.nowPlayingTrack
}))
)
if (currentState !== this.lastAlbumState) {
this.lastAlbumState = currentState
logger.music('debug', '📤 Albums changed, sending update')
logger.music('debug', `Current state: ${currentState}`)
return { albums: enrichedAlbums }
} else {
logger.music('debug', '🔄 No changes detected, skipping update')
}
return {}
} catch (error) {
logger.error('Error checking for updates:', error as Error, undefined, 'music')
return {}
}
}
/**
* Get recent albums from Last.fm tracks
*/
private async getRecentAlbums(limit: number, recentTracksResponse: any): Promise<Album[]> {
const uniqueAlbums = new Map<string, Album>()
for (const track of recentTracksResponse.tracks) {
if (uniqueAlbums.size >= limit) break
const albumKey = track.album.mbid || track.album.name
if (!uniqueAlbums.has(albumKey)) {
uniqueAlbums.set(albumKey, trackToAlbum(track, uniqueAlbums.size + 1))
}
}
return Array.from(uniqueAlbums.values())
}
}

View file

@ -0,0 +1,147 @@
import type { Album } from '$lib/types/lastfm'
import { logger } from '$lib/server/logger'
// Simple buffer time for tracks that might have paused/buffered
const BUFFER_TIME_MS = 30000 // 30 seconds grace period
export class SimpleNowPlayingDetector {
/**
* Check if a track is currently playing based on simple time calculation
*/
isTrackPlaying(scrobbleTime: Date, durationMs: number): boolean {
const now = new Date()
const elapsed = now.getTime() - scrobbleTime.getTime()
const maxPlayTime = durationMs + BUFFER_TIME_MS
const isPlaying = elapsed >= 0 && elapsed <= maxPlayTime
logger.music('debug', `Track playing check: elapsed=${Math.round(elapsed/1000)}s, duration=${Math.round(durationMs/1000)}s, maxPlay=${Math.round(maxPlayTime/1000)}s, isPlaying=${isPlaying}`)
// Track is playing if we're within the duration + buffer
return isPlaying
}
/**
* Process albums and determine which one is playing
* Returns albums with updated isNowPlaying status
*/
async processAlbums(
albums: Album[],
recentTracks: any[],
appleMusicDataLookup: (artistName: string, albumName: string) => Promise<any>
): Promise<Album[]> {
logger.music('debug', `Processing ${albums.length} albums with ${recentTracks.length} recent tracks`)
// First check if Last.fm reports anything as officially playing
const officialNowPlaying = recentTracks.find(track => track.nowPlaying)
if (officialNowPlaying) {
// Trust Last.fm's official now playing status
logger.music('debug', `✅ Last.fm official now playing: "${officialNowPlaying.name}" by ${officialNowPlaying.artist.name}`)
return albums.map(album => ({
...album,
isNowPlaying:
album.name === officialNowPlaying.album.name &&
album.artist.name === officialNowPlaying.artist.name,
nowPlayingTrack:
album.name === officialNowPlaying.album.name &&
album.artist.name === officialNowPlaying.artist.name
? officialNowPlaying.name
: undefined,
lastScrobbleTime:
album.name === officialNowPlaying.album.name &&
album.artist.name === officialNowPlaying.artist.name
? new Date() // Now playing tracks are playing right now
: album.lastScrobbleTime
}))
}
// Fall back to duration-based detection
logger.music('debug', 'Using duration-based detection')
// Find the most recent track across all albums
let mostRecentTrack: any = null
let mostRecentTime = new Date(0)
for (const track of recentTracks) {
if (track.date && track.date > mostRecentTime) {
mostRecentTime = track.date
mostRecentTrack = track
}
}
if (!mostRecentTrack) {
// No recent tracks, nothing is playing
logger.music('debug', '❌ No recent tracks found, nothing is playing')
return albums.map(album => ({
...album,
isNowPlaying: false,
nowPlayingTrack: undefined
}))
}
logger.music('debug', `Most recent track: "${mostRecentTrack.name}" by ${mostRecentTrack.artist.name} from ${mostRecentTrack.album.name}`)
logger.music('debug', `Scrobbled at: ${mostRecentTrack.date}`)
// Check if the most recent track is still playing
const albumKey = `${mostRecentTrack.artist.name}:${mostRecentTrack.album.name}`
let isPlaying = false
let playingTrack: string | undefined
try {
const appleMusicData = await appleMusicDataLookup(
mostRecentTrack.artist.name,
mostRecentTrack.album.name
)
if (appleMusicData?.tracks) {
const trackData = appleMusicData.tracks.find(
(t: any) => t.name.toLowerCase() === mostRecentTrack.name.toLowerCase()
)
if (trackData?.durationMs) {
isPlaying = this.isTrackPlaying(mostRecentTrack.date, trackData.durationMs)
if (isPlaying) {
playingTrack = mostRecentTrack.name
logger.music('debug', `✅ "${playingTrack}" is still playing`)
} else {
logger.music('debug', `❌ "${mostRecentTrack.name}" has finished playing`)
}
} else {
logger.music('debug', `⚠️ No duration found for track "${mostRecentTrack.name}"`)
// Fallback: assume track is playing if scrobbled within last 5 minutes
const timeSinceScrobble = Date.now() - mostRecentTrack.date.getTime()
if (timeSinceScrobble < 5 * 60 * 1000) { // 5 minutes
isPlaying = true
playingTrack = mostRecentTrack.name
logger.music('debug', `⏰ Using time-based fallback: track scrobbled ${Math.round(timeSinceScrobble/1000)}s ago, assuming still playing`)
}
}
}
} catch (error) {
logger.error('Error checking track duration:', error as Error, undefined, 'music')
logger.music('debug', `❌ Failed to get Apple Music data for ${mostRecentTrack.artist.name} - ${mostRecentTrack.album.name}`)
// Fallback when Apple Music lookup fails
const timeSinceScrobble = Date.now() - mostRecentTrack.date.getTime()
if (timeSinceScrobble < 5 * 60 * 1000) { // 5 minutes
isPlaying = true
playingTrack = mostRecentTrack.name
logger.music('debug', `⏰ Using time-based fallback after Apple Music error: track scrobbled ${Math.round(timeSinceScrobble/1000)}s ago`)
}
}
// Update albums with the result
return albums.map(album => {
const key = `${album.artist.name}:${album.name}`
const isThisAlbumPlaying = isPlaying && key === albumKey
return {
...album,
isNowPlaying: isThisAlbumPlaying,
nowPlayingTrack: isThisAlbumPlaying ? playingTrack : undefined,
lastScrobbleTime: isThisAlbumPlaying ? mostRecentTrack.date : album.lastScrobbleTime
}
})
}
}

View file

@ -3,6 +3,7 @@
import { page } from '$app/stores'
import Header from '$components/Header.svelte'
import Footer from '$components/Footer.svelte'
import DebugPanel from '$components/DebugPanel.svelte'
import { generatePersonJsonLd } from '$lib/utils/metadata'
import { Toaster } from 'svelte-sonner'
@ -54,6 +55,9 @@
}}
/>
<!-- Debug Panel (dev only) -->
<DebugPanel />
<style lang="scss">
:global(html) {
background: var(--bg-color);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -187,6 +187,15 @@ async function addAppleMusicDataToAlbums(albums: Album[]): Promise<Album[]> {
}
async function searchAppleMusicForAlbum(album: Album): Promise<Album> {
const searchMetadata = {
searchTime: new Date().toISOString(),
searchQuery: `${album.artist.name} ${album.name}`,
artist: album.artist.name,
album: album.name,
found: false,
error: null as string | null
}
try {
// Check cache first
const cacheKey = `apple:album:${album.artist.name}:${album.name}`
@ -217,6 +226,7 @@ async function searchAppleMusicForAlbum(album: Album): Promise<Album> {
if (appleMusicAlbum) {
const transformedData = await transformAlbumData(appleMusicAlbum)
searchMetadata.found = true
// Cache the result for 24 hours
await redis.set(cacheKey, JSON.stringify(transformedData), 'EX', 86400)
@ -232,16 +242,39 @@ async function searchAppleMusicForAlbum(album: Album): Promise<Album> {
},
appleMusicData: transformedData
}
} else {
// Store search metadata for failed searches
searchMetadata.error = 'No matching album found'
// Cache the failed search metadata for 1 hour
const failedSearchData = {
searchMetadata,
notFound: true
}
await redis.set(cacheKey, JSON.stringify(failedSearchData), 'EX', 3600)
}
} catch (error) {
searchMetadata.error = error instanceof Error ? error.message : 'Unknown error'
console.error(
`Failed to fetch Apple Music data for "${album.name}" by "${album.artist.name}":`,
error
)
// Cache the error metadata for 30 minutes
const errorData = {
searchMetadata,
error: true
}
await redis.set(`apple:album:${album.artist.name}:${album.name}`, JSON.stringify(errorData), 'EX', 1800)
}
// Return album unchanged if Apple Music search fails
return album
// Return album with search metadata if Apple Music search fails
return {
...album,
appleMusicData: {
searchMetadata
}
}
}
function transformImages(images: LastfmImage[]): AlbumImages {

View file

@ -1,6 +1,6 @@
import { LastClient } from '@musicorum/lastfm'
import type { RequestHandler } from './$types'
import { LastfmStreamManager } from '$lib/utils/lastfmStreamManager'
import { SimpleLastfmStreamManager } from '$lib/utils/simpleLastfmStreamManager'
import { logger } from '$lib/server/logger'
const LASTFM_API_KEY = process.env.LASTFM_API_KEY
@ -14,7 +14,7 @@ export const GET: RequestHandler = async ({ request }) => {
const stream = new ReadableStream({
async start(controller) {
const client = new LastClient(LASTFM_API_KEY || '')
const streamManager = new LastfmStreamManager(client, USERNAME)
const streamManager = new SimpleLastfmStreamManager(client, USERNAME)
let intervalId: NodeJS.Timeout | null = null
let isClosed = false
let currentInterval = UPDATE_INTERVAL
@ -39,65 +39,91 @@ export const GET: RequestHandler = async ({ request }) => {
try {
const update = await streamManager.checkForUpdates()
// Check if music is playing
let musicIsPlaying = false
// Send album updates if any
if (update.albums && !isClosed) {
try {
const data = JSON.stringify(update.albums)
controller.enqueue(encoder.encode(`event: albums\ndata: ${data}\n\n`))
// Check if music is playing and calculate smart interval
const nowPlayingAlbum = update.albums.find((a) => a.isNowPlaying)
musicIsPlaying = !!nowPlayingAlbum
const musicIsPlaying = !!nowPlayingAlbum
logger.music('debug', 'Sent album update with now playing status:', {
totalAlbums: update.albums.length,
nowPlayingAlbum: nowPlayingAlbum
? `${nowPlayingAlbum.artist.name} - ${nowPlayingAlbum.name}`
: 'none'
})
} catch (e) {
isClosed = true
}
}
// Send now playing updates if any
if (update.nowPlayingUpdates && update.nowPlayingUpdates.length > 0 && !isClosed) {
try {
const data = JSON.stringify(update.nowPlayingUpdates)
controller.enqueue(encoder.encode(`event: nowplaying\ndata: ${data}\n\n`))
// Check if any of the updates indicate music is playing
musicIsPlaying = musicIsPlaying || update.nowPlayingUpdates.some(u => u.isNowPlaying)
} catch (e) {
isClosed = true
}
}
// Adjust polling interval based on playing state
if (musicIsPlaying !== isPlaying) {
isPlaying = musicIsPlaying
const newInterval = isPlaying ? FAST_UPDATE_INTERVAL : UPDATE_INTERVAL
if (newInterval !== currentInterval) {
currentInterval = newInterval
logger.music('debug', `Adjusting polling interval to ${currentInterval}ms (playing: ${isPlaying})`)
// Reset interval with new timing
if (intervalId) {
clearInterval(intervalId)
intervalId = setInterval(checkForUpdates, currentInterval)
// Calculate remaining time if we have track duration
let remainingMs = 0
if (nowPlayingAlbum?.nowPlayingTrack && nowPlayingAlbum.appleMusicData?.tracks) {
const track = nowPlayingAlbum.appleMusicData.tracks.find(
t => t.name === nowPlayingAlbum.nowPlayingTrack
)
if (track?.durationMs && nowPlayingAlbum.lastScrobbleTime) {
const elapsed = Date.now() - new Date(nowPlayingAlbum.lastScrobbleTime).getTime()
remainingMs = Math.max(0, track.durationMs - elapsed)
}
}
logger.music('debug', '📤 SSE: Sent album update:', {
totalAlbums: update.albums.length,
nowPlaying: nowPlayingAlbum
? `${nowPlayingAlbum.artist.name} - ${nowPlayingAlbum.name}`
: 'none',
remainingMs: remainingMs,
albumsWithStatus: update.albums.map(a => ({
name: a.name,
artist: a.artist.name,
isNowPlaying: a.isNowPlaying,
track: a.nowPlayingTrack
}))
})
// Smart interval adjustment based on remaining track time
let targetInterval = UPDATE_INTERVAL // Default 30s
if (musicIsPlaying && remainingMs > 0) {
// If track is ending soon (within 20 seconds), check more frequently
if (remainingMs < 20000) {
targetInterval = 5000 // 5 seconds
}
// If track has 20-60 seconds left, moderate frequency
else if (remainingMs < 60000) {
targetInterval = 10000 // 10 seconds
}
// If track has more than 60 seconds, check every 15 seconds
else {
targetInterval = 15000 // 15 seconds
}
} else if (musicIsPlaying) {
// If playing but no duration info, use fast interval
targetInterval = FAST_UPDATE_INTERVAL
}
// Apply new interval if it changed significantly (more than 1 second difference)
if (Math.abs(targetInterval - currentInterval) > 1000) {
currentInterval = targetInterval
logger.music('debug', `Adjusting interval to ${currentInterval}ms (playing: ${isPlaying}, remaining: ${Math.round(remainingMs/1000)}s)`)
// Reset interval with new timing
if (intervalId) {
clearInterval(intervalId)
intervalId = setInterval(checkForUpdates, currentInterval)
}
}
} catch (e) {
isClosed = true
}
}
// Send heartbeat to keep connection alive
// Always send heartbeat with timestamp to keep client synced
if (!isClosed) {
try {
controller.enqueue(encoder.encode('event: heartbeat\ndata: {}\n\n'))
const heartbeatData = JSON.stringify({
timestamp: new Date().toISOString(),
interval: currentInterval,
hasUpdates: !!update.albums
})
controller.enqueue(encoder.encode(`event: heartbeat\ndata: ${heartbeatData}\n\n`))
} catch (e) {
// This is expected when client disconnects
// Expected when client disconnects
isClosed = true
}
}
@ -140,4 +166,4 @@ export const GET: RequestHandler = async ({ request }) => {
'X-Accel-Buffering': 'no' // Disable Nginx buffering
}
})
}
}