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[]
}>(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
} catch (error) {
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}`)
}
// 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
export async function findAlbum(artist: string, album: string): Promise<AppleMusicAlbum | null> {
const identifier = `${artist}:${album}`
@ -174,6 +167,17 @@ export async function findAlbum(artist: string, album: string): Promise<AppleMus
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
async function searchAndMatch(
searchAlbum: string,
@ -195,74 +199,114 @@ export async function findAlbum(artist: string, album: string): Promise<AppleMus
const albums = response.results.albums.data
logger.music('debug', `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()
// Log all album results for debugging
albums.forEach((a, index) => {
logger.music(
'debug',
`Album ${index + 1}: "${a.attributes?.name}" by "${a.attributes?.artistName}"`
)
})
// 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()
// Helper function to check if albums match
const albumsMatch = (albumName: string, searchTerm: string, exact = false): boolean => {
if (exact) {
return albumName === searchTerm
}
const albumLower = albumName.toLowerCase()
const searchLower = searchTerm.toLowerCase()
return (
albumLower === searchLower ||
albumLower.startsWith(searchLower) ||
albumLower.includes(searchLower)
)
}
// 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())
)
// Helper function to check if artists match
const artistsMatch = (artistName: string, searchArtist: string, exact = false): boolean => {
if (exact) {
return artistName === searchArtist
}
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
}
try {
// First try with the original album name in US storefront
let result = await searchAndMatch(album)
// If no match, try Japanese storefront
if (!result) {
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
if (!result) {
// Try different album variations
const albumVariations = [album]
const cleanedAlbum = removeLeadingPunctuation(album)
if (cleanedAlbum !== album && cleanedAlbum.length > 0) {
logger.music(
'debug',
`No match found for "${album}", trying without leading punctuation: "${cleanedAlbum}"`
)
result = await searchAndMatch(cleanedAlbum)
albumVariations.push(cleanedAlbum)
}
// Also try Japanese storefront with cleaned album name
if (!result) {
logger.music('debug', `Still no match, trying Japanese storefront with cleaned name`)
result = await searchAndMatch(cleanedAlbum, JAPANESE_STOREFRONT)
// Try each variation in both storefronts
for (const albumVariation of albumVariations) {
for (const storefront of [primaryStorefront, secondaryStorefront]) {
logger.music('debug', `Searching for "${albumVariation}" in ${storefront} storefront`)
const result = await searchAndMatch(albumVariation, storefront)
if (result) {
// Store the storefront information with the album
const matchedAlbum = result.album as any
matchedAlbum._storefront = result.storefront
return result.album
}
}
}
// 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) {
logger.error(
`Failed to find album "${album}" by "${artist}":`,
@ -296,16 +340,6 @@ export async function transformAlbumData(appleMusicAlbum: AppleMusicAlbum) {
included?: AppleMusicTrack[]
}>(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
const albumData = response.data?.[0]
const tracksData = albumData?.relationships?.tracks?.data
@ -322,22 +356,12 @@ export async function transformAlbumData(appleMusicAlbum: AppleMusicAlbum) {
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
if (!previewUrl) {
for (const track of tracksData) {
if (track.type === 'songs' && track.attributes?.previews?.[0]?.url) {
previewUrl = track.attributes.previews[0].url
logger.music('debug', `Using preview URL from track "${track.attributes.name}"`)
break
}
const trackWithPreview = tracks.find((t) => t.previewUrl)
if (trackWithPreview) {
previewUrl = trackWithPreview.previewUrl
logger.music('debug', `Using preview URL from track "${trackWithPreview.name}"`)
}
}
} else {