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:
commit
c90c2a9bdd
29 changed files with 2619 additions and 173 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -32,3 +32,4 @@ vite.config.ts.timestamp-*
|
||||||
*storybook.log
|
*storybook.log
|
||||||
storybook-static
|
storybook-static
|
||||||
backups/
|
backups/
|
||||||
|
server.log
|
||||||
|
|
|
||||||
3
src/assets/icons/check.svg
Normal file
3
src/assets/icons/check.svg
Normal 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 |
4
src/assets/icons/clock.svg
Normal file
4
src/assets/icons/clock.svg
Normal 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 |
3
src/assets/icons/loader.svg
Normal file
3
src/assets/icons/loader.svg
Normal 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 |
4
src/assets/icons/settings.svg
Normal file
4
src/assets/icons/settings.svg
Normal 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 |
3
src/assets/icons/trash.svg
Normal file
3
src/assets/icons/trash.svg
Normal 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
3
src/assets/icons/x.svg
Normal 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 |
|
|
@ -2,10 +2,9 @@
|
||||||
import { Spring } from 'svelte/motion'
|
import { Spring } from 'svelte/motion'
|
||||||
import type { Album } from '$lib/types/lastfm'
|
import type { Album } from '$lib/types/lastfm'
|
||||||
import { audioPreview } from '$lib/stores/audio-preview'
|
import { audioPreview } from '$lib/stores/audio-preview'
|
||||||
import { nowPlayingStream } from '$lib/stores/now-playing-stream'
|
|
||||||
import NowPlaying from './NowPlaying.svelte'
|
import NowPlaying from './NowPlaying.svelte'
|
||||||
import PlayIcon from '$icons/play.svg'
|
import PlayIcon from '$icons/play.svg?component'
|
||||||
import PauseIcon from '$icons/pause.svg'
|
import PauseIcon from '$icons/pause.svg?component'
|
||||||
|
|
||||||
interface AlbumProps {
|
interface AlbumProps {
|
||||||
album?: Album
|
album?: Album
|
||||||
|
|
@ -32,8 +31,8 @@
|
||||||
})
|
})
|
||||||
|
|
||||||
const scale = new Spring(1, {
|
const scale = new Spring(1, {
|
||||||
stiffness: 0.2,
|
stiffness: 0.3,
|
||||||
damping: 0.12
|
damping: 0.25
|
||||||
})
|
})
|
||||||
|
|
||||||
// Determine if this album should shrink
|
// Determine if this album should shrink
|
||||||
|
|
@ -41,9 +40,9 @@
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (isHovering) {
|
if (isHovering) {
|
||||||
scale.target = 1.1
|
scale.target = 1.05
|
||||||
} else if (shouldShrink) {
|
} else if (shouldShrink) {
|
||||||
scale.target = 0.95
|
scale.target = 0.97
|
||||||
} else {
|
} else {
|
||||||
scale.target = 1
|
scale.target = 1
|
||||||
}
|
}
|
||||||
|
|
@ -99,32 +98,17 @@
|
||||||
|
|
||||||
const hasPreview = $derived(!!album?.appleMusicData?.previewUrl)
|
const hasPreview = $derived(!!album?.appleMusicData?.previewUrl)
|
||||||
|
|
||||||
// Subscribe to real-time now playing updates
|
// Use the album's isNowPlaying status directly - single source of truth
|
||||||
let realtimeNowPlaying = $state<{ isNowPlaying: boolean; nowPlayingTrack?: string } | null>(null)
|
const isNowPlaying = $derived(album?.isNowPlaying ?? false)
|
||||||
|
const nowPlayingTrack = $derived(album?.nowPlayingTrack)
|
||||||
$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)
|
|
||||||
|
|
||||||
// Debug logging
|
// Debug logging
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (album && isNowPlaying) {
|
if (album && (isNowPlaying || album.isNowPlaying)) {
|
||||||
console.log(`Album "${album.name}" is now playing:`, {
|
console.log(`🎵 Album component "${album.name}":`, {
|
||||||
fromRealtime: realtimeNowPlaying?.isNowPlaying,
|
isNowPlaying,
|
||||||
fromAlbum: album?.isNowPlaying,
|
nowPlayingTrack,
|
||||||
track: nowPlayingTrack
|
albumData: album
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -165,9 +149,9 @@
|
||||||
class:playing={isPlaying}
|
class:playing={isPlaying}
|
||||||
>
|
>
|
||||||
{#if isPlaying}
|
{#if isPlaying}
|
||||||
<svelte:component this={PauseIcon} />
|
<PauseIcon />
|
||||||
{:else}
|
{:else}
|
||||||
<svelte:component this={PlayIcon} />
|
<PlayIcon />
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
441
src/lib/components/AppleMusicSearchModal.svelte
Normal file
441
src/lib/components/AppleMusicSearchModal.svelte
Normal 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>
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
<script>
|
<script>
|
||||||
import { onMount, onDestroy } from 'svelte'
|
import { onMount, onDestroy } from 'svelte'
|
||||||
import { Spring } from 'svelte/motion'
|
import { Spring } from 'svelte/motion'
|
||||||
import { nowPlayingStream } from '$lib/stores/now-playing-stream'
|
import { musicStream } from '$lib/stores/music-stream'
|
||||||
import { albumStream } from '$lib/stores/album-stream'
|
|
||||||
import AvatarSVG from './AvatarSVG.svelte'
|
import AvatarSVG from './AvatarSVG.svelte'
|
||||||
import AvatarHeadphones from './AvatarHeadphones.svelte'
|
import AvatarHeadphones from './AvatarHeadphones.svelte'
|
||||||
import { get } from 'svelte/store'
|
|
||||||
|
|
||||||
// Props for testing/forcing states
|
// Props for testing/forcing states
|
||||||
let { forcePlayingMusic = false } = $props()
|
let { forcePlayingMusic = false } = $props()
|
||||||
|
|
@ -14,9 +12,6 @@
|
||||||
let isBlinking = $state(false)
|
let isBlinking = $state(false)
|
||||||
let isPlayingMusic = $state(forcePlayingMusic)
|
let isPlayingMusic = $state(forcePlayingMusic)
|
||||||
|
|
||||||
// Track store subscriptions for debugging
|
|
||||||
let nowPlayingStoreState = $state(null)
|
|
||||||
let albumStoreState = $state(null)
|
|
||||||
|
|
||||||
const scale = new Spring(1, {
|
const scale = new Spring(1, {
|
||||||
stiffness: 0.1,
|
stiffness: 0.1,
|
||||||
|
|
@ -66,56 +61,25 @@
|
||||||
}
|
}
|
||||||
}, 4000)
|
}, 4000)
|
||||||
|
|
||||||
// Subscribe to now playing updates from both sources
|
// Subscribe to music stream - single source of truth
|
||||||
const unsubscribeNowPlaying = nowPlayingStream.subscribe((state) => {
|
const unsubscribe = musicStream.nowPlaying.subscribe((nowPlaying) => {
|
||||||
nowPlayingStoreState = state
|
|
||||||
// Check if any album is currently playing, unless forced
|
|
||||||
if (!forcePlayingMusic) {
|
if (!forcePlayingMusic) {
|
||||||
const nowPlayingFromStream = Array.from(state.updates.values()).some(
|
isPlayingMusic = !!nowPlaying
|
||||||
(update) => update.isNowPlaying
|
if (nowPlaying) {
|
||||||
)
|
console.log('Avatar - music playing:', {
|
||||||
console.log('Avatar - nowPlayingStream update:', {
|
artist: nowPlaying.album.artist.name,
|
||||||
updatesCount: state.updates.size,
|
album: nowPlaying.album.name,
|
||||||
hasNowPlaying: nowPlayingFromStream
|
track: nowPlaying.track
|
||||||
})
|
})
|
||||||
// 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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 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 () => {
|
return () => {
|
||||||
if (blinkInterval) {
|
if (blinkInterval) {
|
||||||
clearInterval(blinkInterval)
|
clearInterval(blinkInterval)
|
||||||
}
|
}
|
||||||
unsubscribeNowPlaying()
|
unsubscribe()
|
||||||
unsubscribeAlbums()
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
1058
src/lib/components/DebugPanel.svelte
Normal file
1058
src/lib/components/DebugPanel.svelte
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -3,8 +3,7 @@
|
||||||
import SegmentedController from './SegmentedController.svelte'
|
import SegmentedController from './SegmentedController.svelte'
|
||||||
import NavDropdown from './NavDropdown.svelte'
|
import NavDropdown from './NavDropdown.svelte'
|
||||||
import NowPlayingBar from './NowPlayingBar.svelte'
|
import NowPlayingBar from './NowPlayingBar.svelte'
|
||||||
import { albumStream } from '$lib/stores/album-stream'
|
import { musicStream } from '$lib/stores/music-stream'
|
||||||
import { nowPlayingStream } from '$lib/stores/now-playing-stream'
|
|
||||||
import type { Album } from '$lib/types/lastfm'
|
import type { Album } from '$lib/types/lastfm'
|
||||||
|
|
||||||
let scrollY = $state(0)
|
let scrollY = $state(0)
|
||||||
|
|
@ -18,40 +17,19 @@
|
||||||
let currentlyPlayingAlbum = $state<Album | null>(null)
|
let currentlyPlayingAlbum = $state<Album | null>(null)
|
||||||
let isPlayingMusic = $state(false)
|
let isPlayingMusic = $state(false)
|
||||||
|
|
||||||
// Subscribe to album updates
|
// Subscribe to music stream updates - single source of truth
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const unsubscribe = albumStream.subscribe((state) => {
|
const unsubscribe = musicStream.nowPlaying.subscribe((nowPlaying) => {
|
||||||
const nowPlaying = state.albums.find((album) => album.isNowPlaying)
|
currentlyPlayingAlbum = nowPlaying?.album || null
|
||||||
currentlyPlayingAlbum = nowPlaying || null
|
|
||||||
isPlayingMusic = !!nowPlaying
|
isPlayingMusic = !!nowPlaying
|
||||||
|
|
||||||
// Debug logging
|
// Debug logging
|
||||||
if (nowPlaying) {
|
console.log('🎧 Header now playing update:', {
|
||||||
console.log('Header: Now playing detected:', {
|
hasNowPlaying: !!nowPlaying,
|
||||||
artist: nowPlaying.artist.name,
|
album: nowPlaying?.album.name,
|
||||||
album: nowPlaying.name,
|
artist: nowPlaying?.album.artist.name,
|
||||||
track: nowPlaying.nowPlayingTrack
|
track: nowPlaying?.track
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
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
|
|
||||||
})
|
})
|
||||||
// Only clear if we explicitly know music stopped
|
|
||||||
if (!hasNowPlaying && currentlyPlayingAlbum && state.updates.size > 0) {
|
|
||||||
// Music stopped
|
|
||||||
currentlyPlayingAlbum = null
|
|
||||||
isPlayingMusic = false
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return unsubscribe
|
return unsubscribe
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Album from '$components/Album.svelte'
|
import Album from '$components/Album.svelte'
|
||||||
import type { Album as AlbumType } from '$lib/types/lastfm'
|
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 {
|
interface RecentAlbumsProps {
|
||||||
albums?: AlbumType[]
|
albums?: AlbumType[]
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
let albums = $state<AlbumType[]>(initialAlbums)
|
let albums = $state<AlbumType[]>(initialAlbums)
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const unsubscribe = albumStream.albums.subscribe((streamAlbums) => {
|
const unsubscribe = musicStream.albums.subscribe((streamAlbums) => {
|
||||||
if (streamAlbums.length > 0) {
|
if (streamAlbums.length > 0) {
|
||||||
albums = streamAlbums
|
albums = streamAlbums
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { nowPlayingStream } from '$lib/stores/now-playing-stream'
|
import { musicStream } from '$lib/stores/music-stream'
|
||||||
|
|
||||||
let isConnected = $state(false)
|
let isConnected = $state(false)
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const unsubscribe = nowPlayingStream.subscribe((state) => {
|
const unsubscribe = musicStream.subscribe((state) => {
|
||||||
isConnected = state.connected
|
isConnected = state.connected
|
||||||
})
|
})
|
||||||
return unsubscribe
|
return unsubscribe
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,18 @@ export async function searchAlbums(
|
||||||
return makeAppleMusicRequest<AppleMusicSearchResponse>(endpoint, query)
|
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(
|
export async function searchTracks(
|
||||||
query: string,
|
query: string,
|
||||||
limit: number = 10
|
limit: number = 10
|
||||||
|
|
@ -154,6 +166,8 @@ function containsJapanese(str: string): boolean {
|
||||||
// Helper function to search for an album by artist and album name
|
// Helper function to search for an album by artist and album name
|
||||||
export async function findAlbum(artist: string, album: string): Promise<AppleMusicAlbum | null> {
|
export async function findAlbum(artist: string, album: string): Promise<AppleMusicAlbum | null> {
|
||||||
const identifier = `${artist}:${album}`
|
const identifier = `${artist}:${album}`
|
||||||
|
|
||||||
|
logger.music('info', `=== SEARCHING FOR ALBUM: "${album}" by "${artist}" ===`)
|
||||||
|
|
||||||
// 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)) {
|
||||||
|
|
@ -175,7 +189,9 @@ export async function findAlbum(artist: string, album: string): Promise<AppleMus
|
||||||
logger.music('debug', `Album search strategy for "${album}" by "${artist}":`, {
|
logger.music('debug', `Album search strategy for "${album}" by "${artist}":`, {
|
||||||
hasJapaneseContent,
|
hasJapaneseContent,
|
||||||
primaryStorefront,
|
primaryStorefront,
|
||||||
secondaryStorefront
|
secondaryStorefront,
|
||||||
|
albumHasJapanese: containsJapanese(album),
|
||||||
|
artistHasJapanese: containsJapanese(artist)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Helper function to perform the album search and matching
|
// 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) => {
|
albums.forEach((a, index) => {
|
||||||
logger.music(
|
logger.music(
|
||||||
'debug',
|
'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
|
// If still no match, cache as not found
|
||||||
await rateLimiter.cacheNotFound(identifier, 3600)
|
await rateLimiter.cacheNotFound(identifier, 3600)
|
||||||
return null
|
return null
|
||||||
|
|
@ -327,8 +445,18 @@ export async function transformAlbumData(appleMusicAlbum: AppleMusicAlbum) {
|
||||||
let previewUrl = attributes.previews?.[0]?.url
|
let previewUrl = attributes.previews?.[0]?.url
|
||||||
let tracks: Array<{ name: string; previewUrl?: string; durationMs?: number }> = []
|
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
|
// Always fetch tracks to get preview URLs
|
||||||
if (appleMusicAlbum.id) {
|
else if (appleMusicAlbum.id) {
|
||||||
try {
|
try {
|
||||||
// Determine which storefront to use
|
// Determine which storefront to use
|
||||||
const storefront = (appleMusicAlbum as any)._storefront || DEFAULT_STOREFRONT
|
const storefront = (appleMusicAlbum as any)._storefront || DEFAULT_STOREFRONT
|
||||||
|
|
@ -350,11 +478,13 @@ export async function transformAlbumData(appleMusicAlbum: AppleMusicAlbum) {
|
||||||
// Process all tracks
|
// Process all tracks
|
||||||
tracks = tracksData
|
tracks = tracksData
|
||||||
.filter((item: any) => item.type === 'songs')
|
.filter((item: any) => item.type === 'songs')
|
||||||
.map((track: any) => ({
|
.map((track: any) => {
|
||||||
name: track.attributes?.name || 'Unknown',
|
return {
|
||||||
previewUrl: track.attributes?.previews?.[0]?.url,
|
name: track.attributes?.name || 'Unknown',
|
||||||
durationMs: track.attributes?.durationInMillis
|
previewUrl: track.attributes?.previews?.[0]?.url,
|
||||||
}))
|
durationMs: track.attributes?.durationInMillis
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Find the first track with a preview if we don't have one
|
// Find the first track with a preview if we don't have one
|
||||||
if (!previewUrl) {
|
if (!previewUrl) {
|
||||||
|
|
|
||||||
165
src/lib/server/cache-manager.ts
Normal file
165
src/lib/server/cache-manager.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
156
src/lib/stores/music-stream.ts
Normal file
156
src/lib/stores/music-stream.ts
Normal 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()
|
||||||
|
|
@ -28,6 +28,7 @@ export interface Album {
|
||||||
images: AlbumImages
|
images: AlbumImages
|
||||||
isNowPlaying?: boolean
|
isNowPlaying?: boolean
|
||||||
nowPlayingTrack?: string
|
nowPlayingTrack?: string
|
||||||
|
lastScrobbleTime?: Date | string
|
||||||
appleMusicData?: {
|
appleMusicData?: {
|
||||||
appleMusicId?: string
|
appleMusicId?: string
|
||||||
highResArtwork?: string
|
highResArtwork?: string
|
||||||
|
|
@ -44,6 +45,24 @@ export interface Album {
|
||||||
previewUrl?: string
|
previewUrl?: string
|
||||||
durationMs?: number
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,8 @@ export function trackToAlbum(track: any, rank: number): Album {
|
||||||
url: track.url,
|
url: track.url,
|
||||||
rank,
|
rank,
|
||||||
isNowPlaying: track.nowPlaying || false,
|
isNowPlaying: track.nowPlaying || false,
|
||||||
nowPlayingTrack: track.nowPlaying ? track.name : undefined
|
nowPlayingTrack: track.nowPlaying ? track.name : undefined,
|
||||||
|
lastScrobbleTime: track.date || track.nowPlaying ? new Date() : undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
107
src/lib/utils/simpleLastfmStreamManager.ts
Normal file
107
src/lib/utils/simpleLastfmStreamManager.ts
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
147
src/lib/utils/simpleNowPlayingDetector.ts
Normal file
147
src/lib/utils/simpleNowPlayingDetector.ts
Normal 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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { page } from '$app/stores'
|
import { page } from '$app/stores'
|
||||||
import Header from '$components/Header.svelte'
|
import Header from '$components/Header.svelte'
|
||||||
import Footer from '$components/Footer.svelte'
|
import Footer from '$components/Footer.svelte'
|
||||||
|
import DebugPanel from '$components/DebugPanel.svelte'
|
||||||
import { generatePersonJsonLd } from '$lib/utils/metadata'
|
import { generatePersonJsonLd } from '$lib/utils/metadata'
|
||||||
import { Toaster } from 'svelte-sonner'
|
import { Toaster } from 'svelte-sonner'
|
||||||
|
|
||||||
|
|
@ -54,6 +55,9 @@
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Debug Panel (dev only) -->
|
||||||
|
<DebugPanel />
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
:global(html) {
|
:global(html) {
|
||||||
background: var(--bg-color);
|
background: var(--bg-color);
|
||||||
|
|
|
||||||
33
src/routes/api/admin/debug/apple-music-search/+server.ts
Normal file
33
src/routes/api/admin/debug/apple-music-search/+server.ts
Normal 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' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
50
src/routes/api/admin/debug/clear-cache/+server.ts
Normal file
50
src/routes/api/admin/debug/clear-cache/+server.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
41
src/routes/api/admin/debug/redis-keys/+server.ts
Normal file
41
src/routes/api/admin/debug/redis-keys/+server.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/routes/api/admin/debug/test-find-album/+server.ts
Normal file
33
src/routes/api/admin/debug/test-find-album/+server.ts
Normal 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' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
55
src/routes/api/admin/debug/test-simple-search/+server.ts
Normal file
55
src/routes/api/admin/debug/test-simple-search/+server.ts
Normal 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' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -187,6 +187,15 @@ async function addAppleMusicDataToAlbums(albums: Album[]): Promise<Album[]> {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function searchAppleMusicForAlbum(album: 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 {
|
try {
|
||||||
// Check cache first
|
// Check cache first
|
||||||
const cacheKey = `apple:album:${album.artist.name}:${album.name}`
|
const cacheKey = `apple:album:${album.artist.name}:${album.name}`
|
||||||
|
|
@ -217,6 +226,7 @@ async function searchAppleMusicForAlbum(album: Album): Promise<Album> {
|
||||||
|
|
||||||
if (appleMusicAlbum) {
|
if (appleMusicAlbum) {
|
||||||
const transformedData = await transformAlbumData(appleMusicAlbum)
|
const transformedData = await transformAlbumData(appleMusicAlbum)
|
||||||
|
searchMetadata.found = true
|
||||||
|
|
||||||
// Cache the result for 24 hours
|
// Cache the result for 24 hours
|
||||||
await redis.set(cacheKey, JSON.stringify(transformedData), 'EX', 86400)
|
await redis.set(cacheKey, JSON.stringify(transformedData), 'EX', 86400)
|
||||||
|
|
@ -232,16 +242,39 @@ async function searchAppleMusicForAlbum(album: Album): Promise<Album> {
|
||||||
},
|
},
|
||||||
appleMusicData: transformedData
|
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) {
|
} catch (error) {
|
||||||
|
searchMetadata.error = error instanceof Error ? error.message : 'Unknown error'
|
||||||
console.error(
|
console.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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 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 with search metadata if Apple Music search fails
|
||||||
return album
|
return {
|
||||||
|
...album,
|
||||||
|
appleMusicData: {
|
||||||
|
searchMetadata
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function transformImages(images: LastfmImage[]): AlbumImages {
|
function transformImages(images: LastfmImage[]): AlbumImages {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { LastClient } from '@musicorum/lastfm'
|
import { LastClient } from '@musicorum/lastfm'
|
||||||
import type { RequestHandler } from './$types'
|
import type { RequestHandler } from './$types'
|
||||||
import { LastfmStreamManager } from '$lib/utils/lastfmStreamManager'
|
import { SimpleLastfmStreamManager } from '$lib/utils/simpleLastfmStreamManager'
|
||||||
import { logger } from '$lib/server/logger'
|
import { logger } from '$lib/server/logger'
|
||||||
|
|
||||||
const LASTFM_API_KEY = process.env.LASTFM_API_KEY
|
const LASTFM_API_KEY = process.env.LASTFM_API_KEY
|
||||||
|
|
@ -14,7 +14,7 @@ export const GET: RequestHandler = async ({ request }) => {
|
||||||
const stream = new ReadableStream({
|
const stream = new ReadableStream({
|
||||||
async start(controller) {
|
async start(controller) {
|
||||||
const client = new LastClient(LASTFM_API_KEY || '')
|
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 intervalId: NodeJS.Timeout | null = null
|
||||||
let isClosed = false
|
let isClosed = false
|
||||||
let currentInterval = UPDATE_INTERVAL
|
let currentInterval = UPDATE_INTERVAL
|
||||||
|
|
@ -39,65 +39,91 @@ export const GET: RequestHandler = async ({ request }) => {
|
||||||
try {
|
try {
|
||||||
const update = await streamManager.checkForUpdates()
|
const update = await streamManager.checkForUpdates()
|
||||||
|
|
||||||
// Check if music is playing
|
|
||||||
let musicIsPlaying = false
|
|
||||||
|
|
||||||
// Send album updates if any
|
// Send album updates if any
|
||||||
if (update.albums && !isClosed) {
|
if (update.albums && !isClosed) {
|
||||||
try {
|
try {
|
||||||
const data = JSON.stringify(update.albums)
|
const data = JSON.stringify(update.albums)
|
||||||
controller.enqueue(encoder.encode(`event: albums\ndata: ${data}\n\n`))
|
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)
|
const nowPlayingAlbum = update.albums.find((a) => a.isNowPlaying)
|
||||||
musicIsPlaying = !!nowPlayingAlbum
|
const musicIsPlaying = !!nowPlayingAlbum
|
||||||
|
|
||||||
logger.music('debug', 'Sent album update with now playing status:', {
|
// Calculate remaining time if we have track duration
|
||||||
totalAlbums: update.albums.length,
|
let remainingMs = 0
|
||||||
nowPlayingAlbum: nowPlayingAlbum
|
if (nowPlayingAlbum?.nowPlayingTrack && nowPlayingAlbum.appleMusicData?.tracks) {
|
||||||
? `${nowPlayingAlbum.artist.name} - ${nowPlayingAlbum.name}`
|
const track = nowPlayingAlbum.appleMusicData.tracks.find(
|
||||||
: 'none'
|
t => t.name === nowPlayingAlbum.nowPlayingTrack
|
||||||
})
|
)
|
||||||
} catch (e) {
|
|
||||||
isClosed = true
|
if (track?.durationMs && nowPlayingAlbum.lastScrobbleTime) {
|
||||||
}
|
const elapsed = Date.now() - new Date(nowPlayingAlbum.lastScrobbleTime).getTime()
|
||||||
}
|
remainingMs = Math.max(0, track.durationMs - elapsed)
|
||||||
|
}
|
||||||
// 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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
if (!isClosed) {
|
||||||
try {
|
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) {
|
} catch (e) {
|
||||||
// This is expected when client disconnects
|
// Expected when client disconnects
|
||||||
isClosed = true
|
isClosed = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -140,4 +166,4 @@ export const GET: RequestHandler = async ({ request }) => {
|
||||||
'X-Accel-Buffering': 'no' // Disable Nginx buffering
|
'X-Accel-Buffering': 'no' // Disable Nginx buffering
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
Loading…
Reference in a new issue