fix: improve Apple Music search for singles and Japanese content

- Add fallback to search for songs when album search fails
- Handle artist name variations (with/without spaces in Japanese names)
- Create synthetic album entries for singles not found as albums
- Add search metadata to track failed searches and debug info
- Fix Hachikō preview detection by normalizing artist names

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Justin Edmund 2025-07-10 21:32:43 -07:00
parent b84a2637c0
commit cb6ee326c8
3 changed files with 192 additions and 10 deletions

View file

@ -104,6 +104,18 @@ export async function searchAlbums(
return makeAppleMusicRequest<AppleMusicSearchResponse>(endpoint, query)
}
// Search for both albums and songs
export async function searchAlbumsAndSongs(
query: string,
limit: number = 10,
storefront: string = DEFAULT_STOREFRONT
): Promise<AppleMusicSearchResponse> {
const encodedQuery = encodeURIComponent(query)
const endpoint = `/catalog/${storefront}/search?types=albums,songs&term=${encodedQuery}&limit=${limit}`
return makeAppleMusicRequest<AppleMusicSearchResponse>(endpoint, query)
}
export async function searchTracks(
query: string,
limit: number = 10
@ -155,6 +167,8 @@ function containsJapanese(str: string): boolean {
export async function findAlbum(artist: string, album: string): Promise<AppleMusicAlbum | null> {
const identifier = `${artist}:${album}`
logger.music('info', `=== SEARCHING FOR ALBUM: "${album}" by "${artist}" ===`)
// Check if this album was already marked as not found
if (await rateLimiter.isNotFoundCached(identifier)) {
logger.music('debug', `Album "${album}" by "${artist}" is cached as not found`)
@ -175,7 +189,9 @@ export async function findAlbum(artist: string, album: string): Promise<AppleMus
logger.music('debug', `Album search strategy for "${album}" by "${artist}":`, {
hasJapaneseContent,
primaryStorefront,
secondaryStorefront
secondaryStorefront,
albumHasJapanese: containsJapanese(album),
artistHasJapanese: containsJapanese(artist)
})
// Helper function to perform the album search and matching
@ -203,7 +219,11 @@ export async function findAlbum(artist: string, album: string): Promise<AppleMus
albums.forEach((a, index) => {
logger.music(
'debug',
`Album ${index + 1}: "${a.attributes?.name}" by "${a.attributes?.artistName}"`
`Album ${index + 1}: "${a.attributes?.name}" by "${a.attributes?.artistName}"`,
{
id: a.id,
hasPreview: !!a.attributes?.previews?.[0]?.url
}
)
})
@ -304,6 +324,104 @@ export async function findAlbum(artist: string, album: string): Promise<AppleMus
}
}
// If no album match found, try searching for it as a single/song
logger.music('debug', `No album found for "${album}" by "${artist}", trying as single/song`)
for (const storefront of [primaryStorefront, secondaryStorefront]) {
try {
const searchQuery = `${artist} ${album}`
logger.music('debug', `Searching for songs with query: "${searchQuery}" in ${storefront}`)
const response = await searchAlbumsAndSongs(searchQuery, 5, storefront)
// Check if we found the song
if (response.results?.songs?.data?.length) {
const songs = response.results.songs.data
logger.music('debug', `Found ${songs.length} songs in ${storefront}`)
// Log all songs for debugging
songs.forEach((s, index) => {
logger.music('debug', `Song ${index + 1}: "${s.attributes?.name}" by "${s.attributes?.artistName}" on "${s.attributes?.albumName}"`)
})
// Find matching song
const matchingSong = songs.find(s => {
const songName = s.attributes?.name || ''
const artistName = s.attributes?.artistName || ''
const albumName = s.attributes?.albumName || ''
// For single/track searches, the "album" parameter from Last.fm might actually be the track name
// Check if this is our song by comparing against the track name
const songNameLower = songName.toLowerCase()
const albumSearchLower = album.toLowerCase()
const artistNameLower = artistName.toLowerCase()
const artistSearchLower = artist.toLowerCase()
// Check if the song name matches what we're looking for
const songMatches = songNameLower === albumSearchLower ||
songNameLower.includes(albumSearchLower) ||
albumSearchLower.includes(songNameLower)
// Check if the artist matches (handle spaces in Japanese names)
const artistNameNormalized = artistNameLower.replace(/\s+/g, '')
const artistSearchNormalized = artistSearchLower.replace(/\s+/g, '')
const artistMatches = artistNameLower === artistSearchLower ||
artistNameNormalized === artistSearchNormalized ||
artistNameLower.includes(artistSearchLower) ||
artistSearchLower.includes(artistNameLower) ||
artistNameNormalized.includes(artistSearchNormalized) ||
artistSearchNormalized.includes(artistNameNormalized)
if (songMatches && artistMatches) {
logger.music('debug', `Found matching song: "${songName}" by "${artistName}" on album "${albumName}"`)
return true
}
return false
})
if (matchingSong) {
// Get the album info from the song
const albumName = matchingSong.attributes?.albumName
if (albumName) {
logger.music('debug', `Found as single/song, searching for album: "${albumName}"`)
// Search for the actual album
const albumResponse = await searchAlbums(`${artist} ${albumName}`, 5, storefront)
if (albumResponse.results?.albums?.data?.length) {
const album = albumResponse.results.albums.data[0]
const matchedAlbum = album as any
matchedAlbum._storefront = storefront
return album
}
}
// If no album found, create a synthetic album from the song
logger.music('debug', `Creating synthetic album from single: "${matchingSong.attributes?.name}"`)
return {
id: `single-${matchingSong.id}`,
type: 'albums' as const,
attributes: {
name: matchingSong.attributes?.albumName || matchingSong.attributes?.name || album,
artistName: matchingSong.attributes?.artistName || artist,
artwork: matchingSong.attributes?.artwork,
genreNames: matchingSong.attributes?.genreNames,
releaseDate: matchingSong.attributes?.releaseDate,
trackCount: 1,
isSingle: true,
// Store the song ID so we can fetch it later
_singleSongId: matchingSong.id,
_singleSongPreview: matchingSong.attributes?.previews?.[0]?.url
},
_storefront: storefront
} as any
}
}
} catch (error) {
logger.error(`Failed to search for single "${album}":`, error as Error, undefined, 'music')
}
}
// If still no match, cache as not found
await rateLimiter.cacheNotFound(identifier, 3600)
return null
@ -327,8 +445,18 @@ export async function transformAlbumData(appleMusicAlbum: AppleMusicAlbum) {
let previewUrl = attributes.previews?.[0]?.url
let tracks: Array<{ name: string; previewUrl?: string; durationMs?: number }> = []
// Check if this is a synthetic single album
if ((attributes as any).isSingle && (attributes as any)._singleSongPreview) {
logger.music('debug', 'Processing synthetic single album')
previewUrl = (attributes as any)._singleSongPreview
tracks = [{
name: attributes.name,
previewUrl: (attributes as any)._singleSongPreview,
durationMs: undefined // We'd need to fetch the song details for duration
}]
}
// Always fetch tracks to get preview URLs
if (appleMusicAlbum.id) {
else if (appleMusicAlbum.id) {
try {
// Determine which storefront to use
const storefront = (appleMusicAlbum as any)._storefront || DEFAULT_STOREFRONT
@ -350,11 +478,13 @@ export async function transformAlbumData(appleMusicAlbum: AppleMusicAlbum) {
// 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
}))
.map((track: any) => {
return {
name: track.attributes?.name || 'Unknown',
previewUrl: track.attributes?.previews?.[0]?.url,
durationMs: track.attributes?.durationInMillis
}
})
// Find the first track with a preview if we don't have one
if (!previewUrl) {

View file

@ -28,6 +28,7 @@ export interface Album {
images: AlbumImages
isNowPlaying?: boolean
nowPlayingTrack?: string
lastScrobbleTime?: Date | string
appleMusicData?: {
appleMusicId?: string
highResArtwork?: string
@ -44,6 +45,24 @@ export interface Album {
previewUrl?: string
durationMs?: number
}>
// Debug information
debug?: {
searchQuery?: string
storefront?: string
responseTime?: number
rawResponse?: any
matchType?: 'exact' | 'fuzzy' | 'single'
searchAttempts?: number
}
// Search metadata for failed searches
searchMetadata?: {
searchTime: string
searchQuery: string
artist: string
album: string
found: boolean
error: string | null
}
}
}

View file

@ -187,6 +187,15 @@ async function addAppleMusicDataToAlbums(albums: Album[]): Promise<Album[]> {
}
async function searchAppleMusicForAlbum(album: Album): Promise<Album> {
const searchMetadata = {
searchTime: new Date().toISOString(),
searchQuery: `${album.artist.name} ${album.name}`,
artist: album.artist.name,
album: album.name,
found: false,
error: null as string | null
}
try {
// Check cache first
const cacheKey = `apple:album:${album.artist.name}:${album.name}`
@ -217,6 +226,7 @@ async function searchAppleMusicForAlbum(album: Album): Promise<Album> {
if (appleMusicAlbum) {
const transformedData = await transformAlbumData(appleMusicAlbum)
searchMetadata.found = true
// Cache the result for 24 hours
await redis.set(cacheKey, JSON.stringify(transformedData), 'EX', 86400)
@ -232,16 +242,39 @@ async function searchAppleMusicForAlbum(album: Album): Promise<Album> {
},
appleMusicData: transformedData
}
} else {
// Store search metadata for failed searches
searchMetadata.error = 'No matching album found'
// Cache the failed search metadata for 1 hour
const failedSearchData = {
searchMetadata,
notFound: true
}
await redis.set(cacheKey, JSON.stringify(failedSearchData), 'EX', 3600)
}
} catch (error) {
searchMetadata.error = error instanceof Error ? error.message : 'Unknown error'
console.error(
`Failed to fetch Apple Music data for "${album.name}" by "${album.artist.name}":`,
error
)
// Cache the error metadata for 30 minutes
const errorData = {
searchMetadata,
error: true
}
await redis.set(`apple:album:${album.artist.name}:${album.name}`, JSON.stringify(errorData), 'EX', 1800)
}
// Return album unchanged if Apple Music search fails
return album
// Return album with search metadata if Apple Music search fails
return {
...album,
appleMusicData: {
searchMetadata
}
}
}
function transformImages(images: LastfmImage[]): AlbumImages {