feat: improve Apple Music search for Japanese content

- Add Japanese character detection to prioritize JP storefront
- Improve album/artist matching logic with helper functions
- Support flexible matching for albums with extra text (e.g., "- Single")
- Handle comma-separated artists by matching primary artist
- Clean up code by removing duplicate logic and verbose logging
- Fix issue where Japanese albums weren't found due to strict matching

This ensures albums like "ランデヴー" properly fetch artwork from Apple Music
even when Last.fm has no images available.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Justin Edmund 2025-07-09 23:56:03 -07:00
parent e0a3ec583d
commit ee6fd2f25e

View file

@ -133,19 +133,6 @@ export async function getAlbumDetails(id: string): Promise<AppleMusicAlbum | nul
included?: AppleMusicTrack[] included?: AppleMusicTrack[]
}>(endpoint, `album:${id}`) }>(endpoint, `album:${id}`)
logger.music('debug', `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) {
logger.music('debug', 'First included track:', { track: response.included[0] })
}
return response.data?.[0] || null return response.data?.[0] || null
} catch (error) { } catch (error) {
logger.error(`Failed to get album details for ID ${id}:`, error as Error, undefined, 'music') logger.error(`Failed to get album details for ID ${id}:`, error as Error, undefined, 'music')
@ -158,6 +145,12 @@ export async function getTrack(id: string): Promise<{ data: AppleMusicTrack[] }>
return makeAppleMusicRequest<{ data: AppleMusicTrack[] }>(endpoint, `track:${id}`) return makeAppleMusicRequest<{ data: AppleMusicTrack[] }>(endpoint, `track:${id}`)
} }
// Helper function to detect if a string contains Japanese characters
function containsJapanese(str: string): boolean {
// Check for Hiragana, Katakana, and Kanji
return /[\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FAF]/.test(str)
}
// Helper function to search for an album by artist and album name // Helper function to search for an album by artist and album name
export async function findAlbum(artist: string, album: string): Promise<AppleMusicAlbum | null> { export async function findAlbum(artist: string, album: string): Promise<AppleMusicAlbum | null> {
const identifier = `${artist}:${album}` const identifier = `${artist}:${album}`
@ -174,6 +167,17 @@ export async function findAlbum(artist: string, album: string): Promise<AppleMus
return str.replace(/^[^\w\s]+/, '').trim() return str.replace(/^[^\w\s]+/, '').trim()
} }
// Determine primary storefront based on content
const hasJapaneseContent = containsJapanese(album) || containsJapanese(artist)
const primaryStorefront = hasJapaneseContent ? JAPANESE_STOREFRONT : DEFAULT_STOREFRONT
const secondaryStorefront = hasJapaneseContent ? DEFAULT_STOREFRONT : JAPANESE_STOREFRONT
logger.music('debug', `Album search strategy for "${album}" by "${artist}":`, {
hasJapaneseContent,
primaryStorefront,
secondaryStorefront
})
// Helper function to perform the album search and matching // Helper function to perform the album search and matching
async function searchAndMatch( async function searchAndMatch(
searchAlbum: string, searchAlbum: string,
@ -195,74 +199,114 @@ export async function findAlbum(artist: string, album: string): Promise<AppleMus
const albums = response.results.albums.data const albums = response.results.albums.data
logger.music('debug', `Found ${albums.length} albums`) logger.music('debug', `Found ${albums.length} albums`)
// First try exact match with original album name // Log all album results for debugging
let match = albums.find( albums.forEach((a, index) => {
(a) => logger.music(
a.attributes?.name?.toLowerCase() === album.toLowerCase() && 'debug',
a.attributes?.artistName?.toLowerCase() === artist.toLowerCase() `Album ${index + 1}: "${a.attributes?.name}" by "${a.attributes?.artistName}"`
) )
})
// If no exact match, try matching with the search term we used // Helper function to check if albums match
if (!match && searchAlbum !== album) { const albumsMatch = (albumName: string, searchTerm: string, exact = false): boolean => {
match = albums.find( if (exact) {
(a) => return albumName === searchTerm
a.attributes?.name?.toLowerCase() === searchAlbum.toLowerCase() && }
a.attributes?.artistName?.toLowerCase() === artist.toLowerCase() const albumLower = albumName.toLowerCase()
const searchLower = searchTerm.toLowerCase()
return (
albumLower === searchLower ||
albumLower.startsWith(searchLower) ||
albumLower.includes(searchLower)
) )
} }
// If no exact match, try partial match // Helper function to check if artists match
if (!match) { const artistsMatch = (artistName: string, searchArtist: string, exact = false): boolean => {
match = albums.find( if (exact) {
(a) => return artistName === searchArtist
a.attributes?.name?.toLowerCase().includes(searchAlbum.toLowerCase()) && }
a.attributes?.artistName?.toLowerCase().includes(artist.toLowerCase()) const artistLower = artistName.toLowerCase()
) const searchLower = searchArtist.toLowerCase()
// Direct match
if (artistLower === searchLower) return true
// Handle comma-separated artists
if (searchArtist.includes(',')) {
const primaryArtist = searchArtist.split(',')[0].trim().toLowerCase()
if (artistLower === primaryArtist || artistLower.includes(primaryArtist)) return true
}
// Reverse check - if the found artist is in our search
return searchLower.includes(artistLower)
} }
// Try different matching strategies in order of preference
let match = albums.find((a) => {
const albumName = a.attributes?.name || ''
const artistName = a.attributes?.artistName || ''
// 1. Exact match (case-insensitive)
if (albumsMatch(albumName, album) && artistsMatch(artistName, artist)) {
return true
}
// 2. For Japanese content, try exact character match
if (
hasJapaneseContent &&
albumsMatch(albumName, album, true) &&
artistsMatch(artistName, artist, true)
) {
return true
}
// 3. Try with cleaned album name if different
if (
searchAlbum !== album &&
albumsMatch(albumName, searchAlbum) &&
artistsMatch(artistName, artist)
) {
return true
}
// 4. Flexible matching for albums with extra text
if (albumsMatch(albumName, album) && artistsMatch(artistName, artist)) {
return true
}
return false
})
return match ? { album: match, storefront } : null return match ? { album: match, storefront } : null
} }
try { try {
// First try with the original album name in US storefront // Try different album variations
let result = await searchAndMatch(album) const albumVariations = [album]
const cleanedAlbum = removeLeadingPunctuation(album)
// If no match, try Japanese storefront if (cleanedAlbum !== album && cleanedAlbum.length > 0) {
if (!result) { albumVariations.push(cleanedAlbum)
logger.music('debug', `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 // Try each variation in both storefronts
if (!result) { for (const albumVariation of albumVariations) {
const cleanedAlbum = removeLeadingPunctuation(album) for (const storefront of [primaryStorefront, secondaryStorefront]) {
if (cleanedAlbum !== album && cleanedAlbum.length > 0) { logger.music('debug', `Searching for "${albumVariation}" in ${storefront} storefront`)
logger.music( const result = await searchAndMatch(albumVariation, storefront)
'debug',
`No match found for "${album}", trying without leading punctuation: "${cleanedAlbum}"`
)
result = await searchAndMatch(cleanedAlbum)
// Also try Japanese storefront with cleaned album name if (result) {
if (!result) { // Store the storefront information with the album
logger.music('debug', `Still no match, trying Japanese storefront with cleaned name`) const matchedAlbum = result.album as any
result = await searchAndMatch(cleanedAlbum, JAPANESE_STOREFRONT) matchedAlbum._storefront = result.storefront
return result.album
} }
} }
} }
// If still no match, cache as not found // If still no match, cache as not found
if (!result) { await rateLimiter.cacheNotFound(identifier, 3600)
await rateLimiter.cacheNotFound(identifier, 3600) return null
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) { } catch (error) {
logger.error( logger.error(
`Failed to find album "${album}" by "${artist}":`, `Failed to find album "${album}" by "${artist}":`,
@ -296,16 +340,6 @@ export async function transformAlbumData(appleMusicAlbum: AppleMusicAlbum) {
included?: AppleMusicTrack[] included?: AppleMusicTrack[]
}>(endpoint, `album:${appleMusicAlbum.id}`) }>(endpoint, `album:${appleMusicAlbum.id}`)
logger.music('debug', `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 // Tracks are in relationships.tracks.data when using ?include=tracks
const albumData = response.data?.[0] const albumData = response.data?.[0]
const tracksData = albumData?.relationships?.tracks?.data const tracksData = albumData?.relationships?.tracks?.data
@ -322,22 +356,12 @@ export async function transformAlbumData(appleMusicAlbum: AppleMusicAlbum) {
durationMs: track.attributes?.durationInMillis durationMs: track.attributes?.durationInMillis
})) }))
// Log track details
tracks.forEach((track, index) => {
logger.music(
'debug',
`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 // Find the first track with a preview if we don't have one
if (!previewUrl) { if (!previewUrl) {
for (const track of tracksData) { const trackWithPreview = tracks.find((t) => t.previewUrl)
if (track.type === 'songs' && track.attributes?.previews?.[0]?.url) { if (trackWithPreview) {
previewUrl = track.attributes.previews[0].url previewUrl = trackWithPreview.previewUrl
logger.music('debug', `Using preview URL from track "${track.attributes.name}"`) logger.music('debug', `Using preview URL from track "${trackWithPreview.name}"`)
break
}
} }
} }
} else { } else {