feat: move the whole metadata/search stack from iTunes to Deezer (1.3)

Apple's iTunes /search returns 403 from server/datacenter IPs, which had broken global
search, charts, album/artist/single downloads and the metadata-editor autofill in
production. Everything now sources metadata from Deezer (free, no key, more requests,
returns explicit versions + real artist images + durations). Web + Android share one
source, so an artist resolves to one consistent name everywhere. Deezer results are
mapped to the legacy "ItunesItem" shape, so the UI + download flow are unchanged.

Backend:
- New backend/lib/deezer.js: lookupTrackMeta, fetchAlbumTracks, searchAlbumTracks,
  fetchArtistAlbums (+ pure pickTrackMatch/mapAlbumTrack for tests).
- metadata.js drops the iTunes lookups; novasonic-server download paths (track, album,
  artist, playlist enrich, spotify artwork) + autofill now use Deezer.
- Artist download uses Deezer discography (real albums only), not the iTunes album search.
- Editor autofill endpoint /api/itunes/lookup -> /api/meta/lookup; removed the dead
  iTunes /search proxy and the Apple charts RSS proxy.
- backend/test/deezer.test.js: unit tests for numbering, mixed-artist album-artist
  stability, explicit flags, the Japanese-title match guard (npm test; RUN_LIVE=1 for live).

Web:
- api.js search/lookups already on Deezer; charts now use Deezer /chart. Artist cards
  show the real Deezer photo. Visible "iTunes"/"Apple Music" labels -> "Deezer"
  (search badge, charts badge, preview bar, editor button, artist heading). SW ns-v51.

Android:
- New DeezerModels.kt with Dz* models + mappers to ItunesItem/ItunesRssEntry.
- ItunesClient is Deezer-backed (search = tracks+albums+artists in parallel; album/artist
  lookups; /chart charts), same method signatures. Visible strings -> Deezer.

Docs: README, CLAUDE.md and CHANGELOG (1.3) updated; Platform Parity Matrix reflects the
shared Deezer source + Android non-local artist page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Opus 4.8 committed 2026-07-31 12:48:33 +02:00
1 parent a65613b5a1
commit 1e69831dd8
19 files changed
+628 -350

No files matched your search

@@ -8,64 +8,81 @@ import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.http.ContentType
import io.ktor.serialization.kotlinx.json.*
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.serialization.json.Json
/**
* Metadata/search client. Backed by **Deezer** (api.deezer.com) — Apple's iTunes /search
* returns 403 from datacenter IPs, and Deezer keeps the phone + web PWA on the *same* source
* (identical naming + explicit versions). Deezer responses are mapped to the legacy ItunesItem
* shape (see DeezerModels.kt) so the screens/view models did not change. The public method names
* are kept for the same reason.
*/
object ItunesClient {
private const val BASE = "https://api.deezer.com"
private val http = HttpClient(Android) {
install(ContentNegotiation) {
// iTunes returns text/javascript instead of application/json — accept any content type
json(Json { ignoreUnknownKeys = true; coerceInputValues = true }, contentType = ContentType.Any)
}
}
/**
* Combined search. Deezer splits by type, so `entity` "album" hits /search/album; anything else
* runs tracks + albums + artists in parallel and merges them (the view model filters by wrapperType).
*/
suspend fun search(
query: String,
entity: String = "musicTrack,album,musicArtist",
limit: Int = 30,
country: String = "us",
): ItunesSearchResponse = http.get("https://itunes.apple.com/search") {
parameter("term", query)
parameter("media", "music")
parameter("entity", entity)
parameter("limit", limit)
parameter("country", country)
}.body()
): ItunesSearchResponse {
if (entity == "album") {
val albums: DzList<DzAlbum> = http.get("$BASE/search/album") {
parameter("q", query); parameter("limit", limit)
}.body()
return ItunesSearchResponse(results = albums.data.map { it.toItunesItem() })
}
return coroutineScope {
val tracksD = async { runCatching { http.get("$BASE/search") { parameter("q", query); parameter("limit", limit) }.body<DzList<DzTrack>>().data }.getOrDefault(emptyList()) }
val albumsD = async { runCatching { http.get("$BASE/search/album") { parameter("q", query); parameter("limit", 12) }.body<DzList<DzAlbum>>().data }.getOrDefault(emptyList()) }
val artistsD = async { runCatching { http.get("$BASE/search/artist") { parameter("q", query); parameter("limit", 8) }.body<DzList<DzArtist>>().data }.getOrDefault(emptyList()) }
val results = artistsD.await().map { it.toItunesItem() } +
albumsD.await().map { it.toItunesItem() } +
tracksD.await().map { it.toItunesItem() }
ItunesSearchResponse(results = results)
}
}
/** All tracks of a Deezer album, in the ItunesItem song shape. */
suspend fun lookupAlbum(collectionId: Long): List<ItunesItem> {
val resp: ItunesSearchResponse = http.get("https://itunes.apple.com/lookup") {
parameter("id", collectionId)
parameter("entity", "song")
}.body()
// First result is the album itself; rest are tracks
return resp.results.filter { it.kind == "song" }
val album: DzAlbum = http.get("$BASE/album/$collectionId").body()
return album.tracks?.data.orEmpty().mapIndexed { i, t -> t.toAlbumTrackItem(album, i) }
}
/** All albums for an iTunes artist */
/** A Deezer artist's real albums (not singles/EPs). */
suspend fun lookupArtistAlbums(artistId: Long): List<ItunesItem> {
val resp: ItunesSearchResponse = http.get("https://itunes.apple.com/lookup") {
parameter("id", artistId)
parameter("entity", "album")
parameter("limit", 200)
val albums: DzList<DzAlbum> = http.get("$BASE/artist/$artistId/albums") {
parameter("limit", 200)
}.body()
return resp.results.filter { it.wrapperType == "collection" }
return albums.data.filter { it.record_type == "album" }.map { it.toItunesItem() }
}
/** Worldwide top tracks for an iTunes artist */
/** Top tracks for a Deezer artist. */
suspend fun lookupArtistTopTracks(artistId: Long, limit: Int = 10): List<ItunesItem> {
val resp: ItunesSearchResponse = http.get("https://itunes.apple.com/lookup") {
parameter("id", artistId)
parameter("entity", "song") // "musicTrack" only works on /search — lookup needs "song"
parameter("limit", limit + 1)
parameter("explicit", "Yes")
val tracks: DzList<DzTrack> = http.get("$BASE/artist/$artistId/top") {
parameter("limit", limit)
}.body()
return resp.results.filter { it.wrapperType == "track" && it.kind == "song" }.take(limit)
return tracks.data.map { it.toItunesItem() }
}
/** Top albums RSS feed for a given country */
/** "Neu & Beliebt" — Deezer's global album chart, mapped to the RSS-entry shape. */
suspend fun topAlbums(country: String = "us", limit: Int = 25): List<ItunesRssEntry> {
val resp: ItunesRssResponse =
http.get("https://itunes.apple.com/$country/rss/topalbums/limit=$limit/json").body()
return resp.feed?.entry ?: emptyList()
val albums: DzList<DzAlbum> = http.get("$BASE/chart/0/albums") {
parameter("limit", limit)
}.body()
return albums.data.map { it.toRssEntry() }
}
}
@@ -0,0 +1,129 @@
package com.novasonic.app.api.model
import kotlinx.serialization.Serializable
// ─── DEEZER API MODELS ────────────────────────────────────────────────────────
// Apple's iTunes /search returns 403 from server IPs, so the Explore/search source is
// Deezer (free, no key, returns explicit flags + artist images + durations). Deezer JSON is
// mapped to the existing ItunesItem/ItunesRssEntry shapes so the screens + view models are
// unchanged. See ItunesClient for the endpoint calls.
@Serializable
data class DzList<T>(val data: List<T> = emptyList())
@Serializable
data class DzArtist(
val id: Long? = null,
val name: String? = null,
val picture_medium: String? = null,
val picture_big: String? = null,
val picture_xl: String? = null,
)
@Serializable
data class DzGenre(val name: String? = null)
@Serializable
data class DzGenres(val data: List<DzGenre> = emptyList())
@Serializable
data class DzAlbum(
val id: Long? = null,
val title: String? = null,
val cover_medium: String? = null,
val cover_big: String? = null,
val cover_xl: String? = null,
val release_date: String? = null,
val nb_tracks: Int? = null,
val record_type: String? = null,
val genres: DzGenres? = null,
val artist: DzArtist? = null,
val tracks: DzList<DzTrack>? = null,
val position: Int? = null,
)
@Serializable
data class DzTrack(
val id: Long? = null,
val title: String? = null,
val duration: Long? = null,
val track_position: Int? = null,
val disk_number: Int? = null,
val explicit_lyrics: Boolean = false,
val preview: String? = null,
val artist: DzArtist? = null,
val album: DzAlbum? = null,
)
// ─── MAPPERS to the legacy ItunesItem shape ───────────────────────────────────
private fun DzAlbum.coverXl(): String? = cover_xl ?: cover_big ?: cover_medium
private fun DzArtist.pictureXl(): String? = picture_xl ?: picture_big ?: picture_medium
/** A Deezer search-result track → ItunesItem (album cover for artwork). */
fun DzTrack.toItunesItem(): ItunesItem = ItunesItem(
wrapperType = "track",
kind = "song",
trackId = id,
artistId = artist?.id,
collectionId = album?.id,
artistName = artist?.name,
collectionName = album?.title,
trackName = title,
artworkUrl100 = album?.coverXl() ?: artist?.pictureXl(),
previewUrl = preview,
trackTimeMillis = duration?.let { it * 1000 },
trackNumber = track_position,
)
/** A Deezer album → ItunesItem collection. */
fun DzAlbum.toItunesItem(): ItunesItem = ItunesItem(
wrapperType = "collection",
kind = "album",
collectionId = id,
artistId = artist?.id,
artistName = artist?.name,
collectionName = title,
artworkUrl100 = coverXl(),
trackCount = nb_tracks,
releaseDate = release_date,
primaryGenreName = genres?.data?.firstOrNull()?.name,
)
/** A Deezer artist → ItunesItem artist (carries a real picture, unlike iTunes). */
fun DzArtist.toItunesItem(): ItunesItem = ItunesItem(
wrapperType = "artist",
artistId = id,
artistName = name,
artworkUrl100 = pictureXl(),
)
/**
* A Deezer album track (from /album/{id}) → ItunesItem song. The embedded tracks carry no
* track_position, so the album ordering supplies the number; genre/release come from the album.
*/
fun DzTrack.toAlbumTrackItem(album: DzAlbum, index: Int): ItunesItem = ItunesItem(
wrapperType = "track",
kind = "song",
trackId = id,
artistId = artist?.id ?: album.artist?.id,
collectionId = album.id,
artistName = artist?.name ?: album.artist?.name,
collectionName = album.title,
trackName = title,
artworkUrl100 = album.coverXl(),
previewUrl = preview,
trackTimeMillis = duration?.let { it * 1000 },
trackNumber = track_position ?: (index + 1),
trackCount = album.nb_tracks,
releaseDate = album.release_date,
primaryGenreName = album.genres?.data?.firstOrNull()?.name,
)
/** A Deezer chart album → ItunesRssEntry (keeps the charts UI unchanged). */
fun DzAlbum.toRssEntry(): ItunesRssEntry = ItunesRssEntry(
name = ItunesLabel(title),
artist = ItunesLabel(artist?.name),
image = listOfNotNull(coverXl()?.let { ItunesLabel(it) }),
id = ItunesId(attributes = ItunesIdAttributes(imId = id?.toString())),
)
@@ -364,7 +364,7 @@ fun ArtistDetailScreen(
color = NsAccent, modifier = Modifier.size(16.dp), strokeWidth = 2.dp,
)
Spacer(Modifier.width(10.dp))
Text("iTunes Daten werden geladen…", fontFamily = Outfit,
Text("Deezer Daten werden geladen…", fontFamily = Outfit,
fontSize = 12.sp, color = NsOnSurface2)
}
}
@@ -231,7 +231,7 @@ fun ItunesArtistScreen(
// iTunes top tracks
if (artistState.iTracks.isNotEmpty()) {
item { ArtistSectionHeader("Weltweit populär (iTunes)") }
item { ArtistSectionHeader("Weltweit populär (Deezer)") }
itemsIndexed(artistState.iTracks, key = { _, t -> t.trackId ?: 0L }) { _, track ->
ItunesArtistTrackRow(
track = track,