feat(android): LibraryApi Retrofit interface + wire types (M8 phase 5.1)

Mirrors flutter_client/lib/api/endpoints/library.dart 1:1.

Wire types (snake_case @SerialName per server JSON):
  - TrackWire — id/title/album/artist/duration/streamUrl + nullable
    track/disc numbers (fields verified against TrackRef.fromJson in
    flutter_client/lib/models/track.dart)
  - ArtistWire / ArtistDetailWire — the detail shape embeds "albums"
  - AlbumWire / AlbumDetailWire — the detail shape embeds "tracks"

The Detail variants are explicit data classes (rather than a generic
envelope) because the server returns ArtistRef fields PLUS the
embedded array in the same object, which kotlinx.serialization can't
deserialize through a polymorphic envelope.

LibraryApi endpoints:
  - getTrack(id)
  - getArtistDetail(id) — ArtistDetailWire
  - getArtistTracks(id) — bare List<TrackWire> (server emits a bare
    array, NOT enveloped; Retrofit handles it via List return type)
  - getAlbumDetail(id) — AlbumDetailWire
  - shuffleLibrary(limit) — bare List<TrackWire>

Home endpoints (/api/home and /api/home/index) deferred to a future
HomeApi file because they have their own (larger) wire types that
only the Home screen consumes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-22 17:56:10 -04:00
parent 156162e3ac
commit a4c20816bc
5 changed files with 149 additions and 0 deletions
@@ -1,6 +1,7 @@
package com.fabledsword.minstrel.api
import com.fabledsword.minstrel.BuildConfig
import com.fabledsword.minstrel.api.endpoints.LibraryApi
import com.fabledsword.minstrel.auth.AuthStore
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import dagger.Module
@@ -70,6 +71,11 @@ object NetworkModule {
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
@Provides
@Singleton
fun provideLibraryApi(retrofit: Retrofit): LibraryApi =
retrofit.create(LibraryApi::class.java)
private const val CONNECT_TIMEOUT_SECONDS = 10L
private const val READ_TIMEOUT_SECONDS = 30L
}
@@ -0,0 +1,43 @@
package com.fabledsword.minstrel.api.endpoints
import com.fabledsword.minstrel.models.wire.AlbumDetailWire
import com.fabledsword.minstrel.models.wire.ArtistDetailWire
import com.fabledsword.minstrel.models.wire.TrackWire
import retrofit2.http.GET
import retrofit2.http.Path
import retrofit2.http.Query
/**
* Retrofit interface for the server's native /api/* library surface.
* Mirrors `flutter_client/lib/api/endpoints/library.dart` 1:1.
*
* Notes on shapes:
* - GET /api/artists/{id} returns ArtistDetailWire (ArtistRef fields
* + embedded "albums" array)
* - GET /api/artists/{id}/tracks returns a bare JSON array, not an
* enveloped object — Retrofit's `List<TrackWire>` return type
* handles that.
* - GET /api/albums/{id} returns AlbumDetailWire (AlbumRef fields
* + embedded "tracks" array)
* - GET /api/library/shuffle returns a bare JSON array.
*
* Home endpoints (/api/home, /api/home/index) live in a separate
* `HomeApi` because they have their own wire types (HomePayload,
* HomeIndex) that are larger and only used by the Home screen.
*/
interface LibraryApi {
@GET("api/tracks/{id}")
suspend fun getTrack(@Path("id") id: String): TrackWire
@GET("api/artists/{id}")
suspend fun getArtistDetail(@Path("id") id: String): ArtistDetailWire
@GET("api/artists/{id}/tracks")
suspend fun getArtistTracks(@Path("id") id: String): List<TrackWire>
@GET("api/albums/{id}")
suspend fun getAlbumDetail(@Path("id") id: String): AlbumDetailWire
@GET("api/library/shuffle")
suspend fun shuffleLibrary(@Query("limit") limit: Int = 100): List<TrackWire>
}
@@ -0,0 +1,38 @@
package com.fabledsword.minstrel.models.wire
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Wire shape for `AlbumRef`. Mirrors `flutter_client/lib/models/album.dart`.
*/
@Serializable
data class AlbumWire(
val id: String,
val title: String,
@SerialName("artist_id") val artistId: String,
@SerialName("sort_title") val sortTitle: String = "",
@SerialName("artist_name") val artistName: String = "",
val year: Int? = null,
@SerialName("track_count") val trackCount: Int = 0,
@SerialName("duration_sec") val durationSec: Int = 0,
@SerialName("cover_url") val coverUrl: String = "",
)
/**
* Wire shape for `GET /api/albums/{id}` — server returns AlbumRef
* fields PLUS an embedded `tracks` array.
*/
@Serializable
data class AlbumDetailWire(
val id: String,
val title: String,
@SerialName("artist_id") val artistId: String,
@SerialName("sort_title") val sortTitle: String = "",
@SerialName("artist_name") val artistName: String = "",
val year: Int? = null,
@SerialName("track_count") val trackCount: Int = 0,
@SerialName("duration_sec") val durationSec: Int = 0,
@SerialName("cover_url") val coverUrl: String = "",
val tracks: List<TrackWire> = emptyList(),
)
@@ -0,0 +1,33 @@
package com.fabledsword.minstrel.models.wire
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Wire shape for `ArtistRef`. Mirrors `flutter_client/lib/models/artist.dart`.
*/
@Serializable
data class ArtistWire(
val id: String,
val name: String,
@SerialName("sort_name") val sortName: String = "",
@SerialName("album_count") val albumCount: Int = 0,
@SerialName("cover_url") val coverUrl: String = "",
)
/**
* Wire shape for `GET /api/artists/{id}` — server returns ArtistRef
* fields PLUS an embedded `albums` array. We can't directly map this
* to ArtistWire because Retrofit/kotlinx.serialization doesn't support
* a single record being two types; an explicit DetailWire spells out
* both halves and the repository handles the split.
*/
@Serializable
data class ArtistDetailWire(
val id: String,
val name: String,
@SerialName("sort_name") val sortName: String = "",
@SerialName("album_count") val albumCount: Int = 0,
@SerialName("cover_url") val coverUrl: String = "",
val albums: List<AlbumWire> = emptyList(),
)
@@ -0,0 +1,29 @@
package com.fabledsword.minstrel.models.wire
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Wire shape for `TrackRef` as the server emits it. Mirrors
* `flutter_client/lib/models/track.dart`'s `TrackRef.fromJson`
* field-for-field; the keys are snake_case because the server is Go
* (json:"album_id" etc.).
*
* Mappers in feature packages translate this to the domain TrackRef
* data class and the `cached_tracks` Room entity. Default values match
* the Flutter Dart-side "?? 0" / "?? ''" fallbacks for forward-compat
* with sparse server responses.
*/
@Serializable
data class TrackWire(
val id: String,
val title: String,
@SerialName("album_id") val albumId: String,
@SerialName("album_title") val albumTitle: String = "",
@SerialName("artist_id") val artistId: String,
@SerialName("artist_name") val artistName: String = "",
@SerialName("track_number") val trackNumber: Int? = null,
@SerialName("disc_number") val discNumber: Int? = null,
@SerialName("duration_sec") val durationSec: Int = 0,
@SerialName("stream_url") val streamUrl: String = "",
)