347 lines
11 KiB
TypeScript
347 lines
11 KiB
TypeScript
import { getAppleMusicHeaders } from './apple-music-auth'
|
|
import type {
|
|
AppleMusicAlbum,
|
|
AppleMusicTrack,
|
|
AppleMusicSearchResponse,
|
|
AppleMusicErrorResponse
|
|
} from '$lib/types/apple-music'
|
|
import { isAppleMusicError } from '$lib/types/apple-music'
|
|
import { ApiRateLimiter } from './rate-limiter'
|
|
|
|
const APPLE_MUSIC_API_BASE = 'https://api.music.apple.com/v1'
|
|
const DEFAULT_STOREFRONT = 'us' // Default to US storefront
|
|
const JAPANESE_STOREFRONT = 'jp' // Japanese storefront
|
|
const RATE_LIMIT_DELAY = 200 // 200ms between requests to stay well under 3000/hour
|
|
|
|
let lastRequestTime = 0
|
|
const rateLimiter = new ApiRateLimiter('apple-music')
|
|
|
|
async function rateLimitedFetch(url: string, options?: RequestInit): Promise<Response> {
|
|
const now = Date.now()
|
|
const timeSinceLastRequest = now - lastRequestTime
|
|
|
|
if (timeSinceLastRequest < RATE_LIMIT_DELAY) {
|
|
await new Promise((resolve) => setTimeout(resolve, RATE_LIMIT_DELAY - timeSinceLastRequest))
|
|
}
|
|
|
|
lastRequestTime = Date.now()
|
|
return fetch(url, options)
|
|
}
|
|
|
|
async function makeAppleMusicRequest<T>(endpoint: string, identifier?: string): Promise<T> {
|
|
// Check if we should block this request
|
|
if (identifier && (await rateLimiter.shouldBlock(identifier))) {
|
|
throw new Error('Request blocked due to rate limiting')
|
|
}
|
|
|
|
const url = `${APPLE_MUSIC_API_BASE}${endpoint}`
|
|
const headers = getAppleMusicHeaders()
|
|
|
|
console.log('Making Apple Music API request:', {
|
|
url,
|
|
headers: {
|
|
...headers,
|
|
Authorization: headers.Authorization ? 'Bearer [TOKEN]' : 'Missing'
|
|
}
|
|
})
|
|
|
|
try {
|
|
const response = await rateLimitedFetch(url, { headers })
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text()
|
|
console.error('Apple Music API error response:', {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
body: errorText
|
|
})
|
|
|
|
// Record failure and handle rate limiting
|
|
if (identifier) {
|
|
await rateLimiter.recordFailure(identifier, response.status === 429)
|
|
}
|
|
|
|
try {
|
|
const errorData = JSON.parse(errorText)
|
|
if (isAppleMusicError(errorData)) {
|
|
throw new Error(
|
|
`Apple Music API Error: ${errorData.errors[0]?.detail || 'Unknown error'}`
|
|
)
|
|
}
|
|
} catch (e) {
|
|
// If not JSON, throw the text error
|
|
}
|
|
|
|
throw new Error(`HTTP ${response.status}: ${response.statusText} - ${errorText}`)
|
|
}
|
|
|
|
// Record success
|
|
if (identifier) {
|
|
await rateLimiter.recordSuccess(identifier)
|
|
}
|
|
|
|
return await response.json()
|
|
} catch (error) {
|
|
console.error('Apple Music API request failed:', error)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
export async function searchAlbums(
|
|
query: string,
|
|
limit: number = 10,
|
|
storefront: string = DEFAULT_STOREFRONT
|
|
): Promise<AppleMusicSearchResponse> {
|
|
const encodedQuery = encodeURIComponent(query)
|
|
const endpoint = `/catalog/${storefront}/search?types=albums&term=${encodedQuery}&limit=${limit}`
|
|
|
|
return makeAppleMusicRequest<AppleMusicSearchResponse>(endpoint, query)
|
|
}
|
|
|
|
export async function searchTracks(
|
|
query: string,
|
|
limit: number = 10
|
|
): Promise<AppleMusicSearchResponse> {
|
|
const encodedQuery = encodeURIComponent(query)
|
|
const endpoint = `/catalog/${DEFAULT_STOREFRONT}/search?types=songs&term=${encodedQuery}&limit=${limit}`
|
|
|
|
return makeAppleMusicRequest<AppleMusicSearchResponse>(endpoint, query)
|
|
}
|
|
|
|
export async function getAlbum(id: string): Promise<{ data: AppleMusicAlbum[] }> {
|
|
const endpoint = `/catalog/${DEFAULT_STOREFRONT}/albums/${id}`
|
|
return makeAppleMusicRequest<{ data: AppleMusicAlbum[] }>(endpoint, `album:${id}`)
|
|
}
|
|
|
|
export async function getAlbumWithTracks(id: string): Promise<{ data: AppleMusicAlbum[] }> {
|
|
const endpoint = `/catalog/${DEFAULT_STOREFRONT}/albums/${id}?include=tracks`
|
|
return makeAppleMusicRequest<{ data: AppleMusicAlbum[] }>(endpoint, `album:${id}`)
|
|
}
|
|
|
|
// Get album with all details including tracks for preview URLs
|
|
export async function getAlbumDetails(id: string): Promise<AppleMusicAlbum | null> {
|
|
try {
|
|
const endpoint = `/catalog/${DEFAULT_STOREFRONT}/albums/${id}?include=tracks`
|
|
const response = await makeAppleMusicRequest<{
|
|
data: AppleMusicAlbum[]
|
|
included?: AppleMusicTrack[]
|
|
}>(endpoint, `album:${id}`)
|
|
|
|
console.log(`Album details for ${id}:`, {
|
|
hasData: !!response.data?.[0],
|
|
hasRelationships: !!response.data?.[0]?.relationships,
|
|
hasTracks: !!response.data?.[0]?.relationships?.tracks,
|
|
hasIncluded: !!response.included,
|
|
includedCount: response.included?.length || 0
|
|
})
|
|
|
|
// Check if tracks are in the included array
|
|
if (response.included?.length) {
|
|
console.log('First included track:', JSON.stringify(response.included[0], null, 2))
|
|
}
|
|
|
|
return response.data?.[0] || null
|
|
} catch (error) {
|
|
console.error(`Failed to get album details for ID ${id}:`, error)
|
|
return null
|
|
}
|
|
}
|
|
|
|
export async function getTrack(id: string): Promise<{ data: AppleMusicTrack[] }> {
|
|
const endpoint = `/catalog/${DEFAULT_STOREFRONT}/songs/${id}`
|
|
return makeAppleMusicRequest<{ data: AppleMusicTrack[] }>(endpoint, `track:${id}`)
|
|
}
|
|
|
|
// 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}`
|
|
|
|
// Check if this album was already marked as not found
|
|
if (await rateLimiter.isNotFoundCached(identifier)) {
|
|
console.log(`Album "${album}" by "${artist}" is cached as not found`)
|
|
return null
|
|
}
|
|
|
|
// Helper function to remove leading punctuation
|
|
function removeLeadingPunctuation(str: string): string {
|
|
// Remove leading punctuation marks like ; ! ? . , : ' " etc.
|
|
return str.replace(/^[^\w\s]+/, '').trim()
|
|
}
|
|
|
|
// Helper function to perform the album search and matching
|
|
async function searchAndMatch(searchAlbum: string, storefront: string = DEFAULT_STOREFRONT): Promise<{album: AppleMusicAlbum, storefront: string} | null> {
|
|
const searchQuery = `${artist} ${searchAlbum}`
|
|
const response = await searchAlbums(searchQuery, 5, storefront)
|
|
|
|
console.log(`Search results for "${searchQuery}" in ${storefront} storefront:`, JSON.stringify(response, null, 2))
|
|
|
|
if (!response.results?.albums?.data?.length) {
|
|
console.log(`No albums found in ${storefront} storefront`)
|
|
return null
|
|
}
|
|
|
|
// Try to find the best match
|
|
const albums = response.results.albums.data
|
|
console.log(`Found ${albums.length} albums`)
|
|
|
|
// First try exact match with original album name
|
|
let match = albums.find(
|
|
(a) =>
|
|
a.attributes?.name?.toLowerCase() === album.toLowerCase() &&
|
|
a.attributes?.artistName?.toLowerCase() === artist.toLowerCase()
|
|
)
|
|
|
|
// If no exact match, try matching with the search term we used
|
|
if (!match && searchAlbum !== album) {
|
|
match = albums.find(
|
|
(a) =>
|
|
a.attributes?.name?.toLowerCase() === searchAlbum.toLowerCase() &&
|
|
a.attributes?.artistName?.toLowerCase() === artist.toLowerCase()
|
|
)
|
|
}
|
|
|
|
// If no exact match, try partial match
|
|
if (!match) {
|
|
match = albums.find(
|
|
(a) =>
|
|
a.attributes?.name?.toLowerCase().includes(searchAlbum.toLowerCase()) &&
|
|
a.attributes?.artistName?.toLowerCase().includes(artist.toLowerCase())
|
|
)
|
|
}
|
|
|
|
return match ? {album: match, storefront} : null
|
|
}
|
|
|
|
try {
|
|
// First try with the original album name in US storefront
|
|
let result = await searchAndMatch(album)
|
|
|
|
// If no match, try Japanese storefront
|
|
if (!result) {
|
|
console.log(`No match found in US storefront, trying Japanese storefront`)
|
|
result = await searchAndMatch(album, JAPANESE_STOREFRONT)
|
|
}
|
|
|
|
// If no match and album starts with punctuation, try without it in both storefronts
|
|
if (!result) {
|
|
const cleanedAlbum = removeLeadingPunctuation(album)
|
|
if (cleanedAlbum !== album && cleanedAlbum.length > 0) {
|
|
console.log(`No match found for "${album}", trying without leading punctuation: "${cleanedAlbum}"`)
|
|
result = await searchAndMatch(cleanedAlbum)
|
|
|
|
// Also try Japanese storefront with cleaned album name
|
|
if (!result) {
|
|
console.log(`Still no match, trying Japanese storefront with cleaned name`)
|
|
result = await searchAndMatch(cleanedAlbum, JAPANESE_STOREFRONT)
|
|
}
|
|
}
|
|
}
|
|
|
|
// If still no match, cache as not found
|
|
if (!result) {
|
|
await rateLimiter.cacheNotFound(identifier, 3600)
|
|
return null
|
|
}
|
|
|
|
// Store the storefront information with the album
|
|
const matchedAlbum = result.album as any
|
|
matchedAlbum._storefront = result.storefront
|
|
|
|
// Return the match
|
|
return result.album
|
|
} catch (error) {
|
|
console.error(`Failed to find album "${album}" by "${artist}":`, error)
|
|
// Don't cache as not found on error - might be temporary
|
|
return null
|
|
}
|
|
}
|
|
|
|
// Transform Apple Music album data to match existing format
|
|
export async function transformAlbumData(appleMusicAlbum: AppleMusicAlbum) {
|
|
const attributes = appleMusicAlbum.attributes
|
|
|
|
// Get preview URL from tracks if album doesn't have one
|
|
let previewUrl = attributes.previews?.[0]?.url
|
|
let tracks: Array<{ name: string; previewUrl?: string; durationMs?: number }> = []
|
|
|
|
// Always fetch tracks to get preview URLs
|
|
if (appleMusicAlbum.id) {
|
|
try {
|
|
// Determine which storefront to use
|
|
const storefront = (appleMusicAlbum as any)._storefront || DEFAULT_STOREFRONT
|
|
|
|
// Fetch album details with tracks
|
|
const endpoint = `/catalog/${storefront}/albums/${appleMusicAlbum.id}?include=tracks`
|
|
const response = await makeAppleMusicRequest<{
|
|
data: AppleMusicAlbum[]
|
|
included?: AppleMusicTrack[]
|
|
}>(endpoint, `album:${appleMusicAlbum.id}`)
|
|
|
|
console.log(`Album details response structure:`, {
|
|
hasData: !!response.data,
|
|
dataLength: response.data?.length,
|
|
hasIncluded: !!response.included,
|
|
includedLength: response.included?.length,
|
|
// Check if tracks are in relationships
|
|
hasRelationships: !!response.data?.[0]?.relationships,
|
|
hasTracks: !!response.data?.[0]?.relationships?.tracks
|
|
})
|
|
|
|
// Tracks are in relationships.tracks.data when using ?include=tracks
|
|
const albumData = response.data?.[0]
|
|
const tracksData = albumData?.relationships?.tracks?.data
|
|
|
|
if (tracksData?.length) {
|
|
console.log(`Found ${tracksData.length} tracks for album "${attributes.name}"`)
|
|
|
|
// 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
|
|
}))
|
|
|
|
// Log track details
|
|
tracks.forEach((track, index) => {
|
|
console.log(
|
|
`Track ${index + 1}: ${track.name} - Preview: ${track.previewUrl ? 'Yes' : 'No'} - Duration: ${track.durationMs}ms`
|
|
)
|
|
})
|
|
|
|
// Find the first track with a preview if we don't have one
|
|
if (!previewUrl) {
|
|
for (const track of tracksData) {
|
|
if (track.type === 'songs' && track.attributes?.previews?.[0]?.url) {
|
|
previewUrl = track.attributes.previews[0].url
|
|
console.log(`Using preview URL from track "${track.attributes.name}"`)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
console.log('No tracks found in album response')
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to fetch album tracks:', error)
|
|
}
|
|
}
|
|
|
|
return {
|
|
appleMusicId: appleMusicAlbum.id,
|
|
highResArtwork: attributes.artwork
|
|
? attributes.artwork.url.replace('{w}x{h}', '3000x3000')
|
|
: undefined,
|
|
previewUrl,
|
|
// Store additional metadata for future use
|
|
genres: attributes.genreNames,
|
|
releaseDate: attributes.releaseDate,
|
|
trackCount: attributes.trackCount,
|
|
recordLabel: attributes.recordLabel,
|
|
copyright: attributes.copyright,
|
|
editorialNotes: attributes.editorialNotes,
|
|
isComplete: attributes.isComplete,
|
|
tracks
|
|
}
|
|
}
|