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-static
|
||||
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 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}
|
||||
|
|
|
|||
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>
|
||||
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>
|
||||
|
|
|
|||
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 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
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
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
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 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);
|
||||
|
|
|
|||
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> {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue