feat: add comprehensive debug panel for development
- Create DebugPanel component with Now Playing, Albums, and Cache tabs - Show real-time connection status and update intervals - Display detailed Apple Music data for each album - Add inline cache clearing for individual albums - Implement Apple Music search modal for testing queries - Add admin endpoints for cache management and API testing - Only visible in development mode 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
cb6ee326c8
commit
f1d0453b63
7 changed files with 1711 additions and 0 deletions
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>
|
||||
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
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' }
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue