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:
parent
e0a3ec583d
commit
ee6fd2f25e
1 changed files with 111 additions and 87 deletions
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue