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:
parent
b84a2637c0
commit
cb6ee326c8
3 changed files with 192 additions and 10 deletions
|
|
@ -104,6 +104,18 @@ export async function searchAlbums(
|
||||||
return makeAppleMusicRequest<AppleMusicSearchResponse>(endpoint, query)
|
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(
|
export async function searchTracks(
|
||||||
query: string,
|
query: string,
|
||||||
limit: number = 10
|
limit: number = 10
|
||||||
|
|
@ -154,6 +166,8 @@ function containsJapanese(str: string): boolean {
|
||||||
// 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}`
|
||||||
|
|
||||||
|
logger.music('info', `=== SEARCHING FOR ALBUM: "${album}" by "${artist}" ===`)
|
||||||
|
|
||||||
// Check if this album was already marked as not found
|
// Check if this album was already marked as not found
|
||||||
if (await rateLimiter.isNotFoundCached(identifier)) {
|
if (await rateLimiter.isNotFoundCached(identifier)) {
|
||||||
|
|
@ -175,7 +189,9 @@ export async function findAlbum(artist: string, album: string): Promise<AppleMus
|
||||||
logger.music('debug', `Album search strategy for "${album}" by "${artist}":`, {
|
logger.music('debug', `Album search strategy for "${album}" by "${artist}":`, {
|
||||||
hasJapaneseContent,
|
hasJapaneseContent,
|
||||||
primaryStorefront,
|
primaryStorefront,
|
||||||
secondaryStorefront
|
secondaryStorefront,
|
||||||
|
albumHasJapanese: containsJapanese(album),
|
||||||
|
artistHasJapanese: containsJapanese(artist)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Helper function to perform the album search and matching
|
// 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) => {
|
albums.forEach((a, index) => {
|
||||||
logger.music(
|
logger.music(
|
||||||
'debug',
|
'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
|
// If still no match, cache as not found
|
||||||
await rateLimiter.cacheNotFound(identifier, 3600)
|
await rateLimiter.cacheNotFound(identifier, 3600)
|
||||||
return null
|
return null
|
||||||
|
|
@ -327,8 +445,18 @@ export async function transformAlbumData(appleMusicAlbum: AppleMusicAlbum) {
|
||||||
let previewUrl = attributes.previews?.[0]?.url
|
let previewUrl = attributes.previews?.[0]?.url
|
||||||
let tracks: Array<{ name: string; previewUrl?: string; durationMs?: number }> = []
|
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
|
// Always fetch tracks to get preview URLs
|
||||||
if (appleMusicAlbum.id) {
|
else if (appleMusicAlbum.id) {
|
||||||
try {
|
try {
|
||||||
// Determine which storefront to use
|
// Determine which storefront to use
|
||||||
const storefront = (appleMusicAlbum as any)._storefront || DEFAULT_STOREFRONT
|
const storefront = (appleMusicAlbum as any)._storefront || DEFAULT_STOREFRONT
|
||||||
|
|
@ -350,11 +478,13 @@ export async function transformAlbumData(appleMusicAlbum: AppleMusicAlbum) {
|
||||||
// Process all tracks
|
// Process all tracks
|
||||||
tracks = tracksData
|
tracks = tracksData
|
||||||
.filter((item: any) => item.type === 'songs')
|
.filter((item: any) => item.type === 'songs')
|
||||||
.map((track: any) => ({
|
.map((track: any) => {
|
||||||
name: track.attributes?.name || 'Unknown',
|
return {
|
||||||
previewUrl: track.attributes?.previews?.[0]?.url,
|
name: track.attributes?.name || 'Unknown',
|
||||||
durationMs: track.attributes?.durationInMillis
|
previewUrl: track.attributes?.previews?.[0]?.url,
|
||||||
}))
|
durationMs: track.attributes?.durationInMillis
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// 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) {
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ export interface Album {
|
||||||
images: AlbumImages
|
images: AlbumImages
|
||||||
isNowPlaying?: boolean
|
isNowPlaying?: boolean
|
||||||
nowPlayingTrack?: string
|
nowPlayingTrack?: string
|
||||||
|
lastScrobbleTime?: Date | string
|
||||||
appleMusicData?: {
|
appleMusicData?: {
|
||||||
appleMusicId?: string
|
appleMusicId?: string
|
||||||
highResArtwork?: string
|
highResArtwork?: string
|
||||||
|
|
@ -44,6 +45,24 @@ export interface Album {
|
||||||
previewUrl?: string
|
previewUrl?: string
|
||||||
durationMs?: number
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -187,6 +187,15 @@ async function addAppleMusicDataToAlbums(albums: Album[]): Promise<Album[]> {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function searchAppleMusicForAlbum(album: 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 {
|
try {
|
||||||
// Check cache first
|
// Check cache first
|
||||||
const cacheKey = `apple:album:${album.artist.name}:${album.name}`
|
const cacheKey = `apple:album:${album.artist.name}:${album.name}`
|
||||||
|
|
@ -217,6 +226,7 @@ async function searchAppleMusicForAlbum(album: Album): Promise<Album> {
|
||||||
|
|
||||||
if (appleMusicAlbum) {
|
if (appleMusicAlbum) {
|
||||||
const transformedData = await transformAlbumData(appleMusicAlbum)
|
const transformedData = await transformAlbumData(appleMusicAlbum)
|
||||||
|
searchMetadata.found = true
|
||||||
|
|
||||||
// Cache the result for 24 hours
|
// Cache the result for 24 hours
|
||||||
await redis.set(cacheKey, JSON.stringify(transformedData), 'EX', 86400)
|
await redis.set(cacheKey, JSON.stringify(transformedData), 'EX', 86400)
|
||||||
|
|
@ -232,16 +242,39 @@ async function searchAppleMusicForAlbum(album: Album): Promise<Album> {
|
||||||
},
|
},
|
||||||
appleMusicData: transformedData
|
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) {
|
} catch (error) {
|
||||||
|
searchMetadata.error = error instanceof Error ? error.message : 'Unknown error'
|
||||||
console.error(
|
console.error(
|
||||||
`Failed to fetch Apple Music data for "${album.name}" by "${album.artist.name}":`,
|
`Failed to fetch Apple Music data for "${album.name}" by "${album.artist.name}":`,
|
||||||
error
|
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 with search metadata if Apple Music search fails
|
||||||
return album
|
return {
|
||||||
|
...album,
|
||||||
|
appleMusicData: {
|
||||||
|
searchMetadata
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function transformImages(images: LastfmImage[]): AlbumImages {
|
function transformImages(images: LastfmImage[]): AlbumImages {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue