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:
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,
|
||||
|
||||
Reference in new issue
Block a user