feat(charts): фильтр по жанру + жанр/стиль/лейбл/год на детальной трека

Подтягиваем обогащённые данные с бэкенда (Discogs): genre/styles/label/year
в чартах и детальной странице.

- ChartEntry/TrackStats + DTO: добавлены genre/styles/label/year
- RadiolaApi: getCharts(?genre=), новый getGenres()
- ChartsViewModel: состояние выбранного жанра + список жанров, перезагрузка
- ChartsScreen: ряд чипов-фильтров по жанру (Все + жанры),
  жанр/стили чипами и «Лейбл · Год» на детальной
- убран демо-fallback (SAMPLE_CHARTS) — бэкенд живой

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nk
2026-06-03 13:55:35 +03:00
parent b0c3dae20a
commit 99503fc77a
8 changed files with 157 additions and 136 deletions

View File

@@ -4,6 +4,7 @@ import com.radiola.data.remote.dto.AuthResponseDto
import com.radiola.data.remote.dto.BackendNowPlayingDto
import com.radiola.data.remote.dto.BackendStationDto
import com.radiola.data.remote.dto.ChartsResponseDto
import com.radiola.data.remote.dto.GenresResponseDto
import com.radiola.data.remote.dto.HistoryResponseDto
import com.radiola.data.remote.dto.MagicLinkRequestDto
import com.radiola.data.remote.dto.MagicLinkVerifyDto
@@ -58,9 +59,13 @@ interface RadiolaApi {
@GET("charts/tracks")
suspend fun getCharts(
@Query("period") period: String,
@Query("limit") limit: Int = 100
@Query("limit") limit: Int = 100,
@Query("genre") genre: String? = null
): ChartsResponseDto
@GET("charts/genres")
suspend fun getGenres(): GenresResponseDto
@GET("charts/tracks/{trackId}")
suspend fun getTrackStats(@Path("trackId") trackId: String): TrackStatsDto

View File

@@ -8,6 +8,12 @@ data class ChartsResponseDto(
val items: List<ChartEntryDto> = emptyList()
)
/** DTO списка доступных жанров для фильтра. */
@Serializable
data class GenresResponseDto(
val genres: List<String> = emptyList()
)
/** Одна позиция в чарте. */
@Serializable
data class ChartEntryDto(
@@ -16,6 +22,10 @@ data class ChartEntryDto(
val artist: String,
val song: String,
val coverUrl: String? = null,
val genre: String? = null,
val styles: List<String> = emptyList(),
val label: String? = null,
val year: Int? = null,
val plays: Int = 0,
val stationsCount: Int = 0,
val likes: Int = 0,
@@ -32,6 +42,10 @@ data class TrackStatsDto(
val song: String,
val album: String? = null,
val coverUrl: String? = null,
val genre: String? = null,
val styles: List<String> = emptyList(),
val label: String? = null,
val year: Int? = null,
val releaseDate: String? = null,
val firstSeen: String? = null,
val totalPlays: Int = 0,

View File

@@ -25,28 +25,29 @@ class ChartsRepositoryImpl @Inject constructor(
private val api: RadiolaApi
) : ChartsRepository {
// TODO: убрать превью, когда бэкенд отдаёт charts
override suspend fun getCharts(period: ChartPeriod): List<ChartEntry> {
override suspend fun getCharts(period: ChartPeriod, genre: String?): List<ChartEntry> {
return try {
val response = api.getCharts(period.apiValue)
val response = api.getCharts(period.apiValue, genre = genre)
response.items.map { it.toDomain() }
} catch (e: Exception) {
Log.w("ChartsRepository", "Эндпоинт charts недоступен, возвращаем превью-данные: ${e.message}")
SAMPLE_CHARTS
Log.w("ChartsRepository", "Ошибка загрузки чартов: ${e.message}")
emptyList()
}
}
// TODO: убрать превью, когда бэкенд отдаёт charts
override suspend fun getTrackStats(trackId: String): TrackStats {
override suspend fun getGenres(): List<String> {
return try {
val dto = api.getTrackStats(trackId)
dto.toDomain()
api.getGenres().genres
} catch (e: Exception) {
Log.w("ChartsRepository", "Эндпоинт getTrackStats недоступен, возвращаем превью-данные: ${e.message}")
sampleStats(trackId)
Log.w("ChartsRepository", "Ошибка загрузки жанров: ${e.message}")
emptyList()
}
}
override suspend fun getTrackStats(trackId: String): TrackStats {
return api.getTrackStats(trackId).toDomain()
}
override suspend fun setLiked(trackId: String, liked: Boolean) {
try {
if (liked) api.likeTrack(trackId) else api.unlikeTrack(trackId)
@@ -63,6 +64,10 @@ class ChartsRepositoryImpl @Inject constructor(
artist = artist,
song = song,
coverUrl = coverUrl,
genre = genre,
styles = styles,
label = label,
year = year,
plays = plays,
stationsCount = stationsCount,
likes = likes,
@@ -81,6 +86,10 @@ class ChartsRepositoryImpl @Inject constructor(
song = song,
album = album,
coverUrl = coverUrl,
genre = genre,
styles = styles,
label = label,
year = year,
releaseDate = releaseDate,
firstSeen = firstSeen,
totalPlays = totalPlays,
@@ -111,87 +120,4 @@ class ChartsRepositoryImpl @Inject constructor(
}
return StatPoint(epochMs, value)
}
// ---- Превью-данные (используются пока бэкенд не готов) ----
// TODO: убрать превью, когда бэкенд отдаёт charts
private fun sampleStats(trackId: String): TrackStats {
val entry = SAMPLE_CHARTS.firstOrNull { it.trackId == trackId }
val now = System.currentTimeMillis()
val dayMs = 86_400_000L
// 30 точек с плавным ростом + шум
fun timeline(base: Int): List<StatPoint> = (0 until 30).map { i ->
val noise = (-base / 20..base / 20).random()
StatPoint(
date = now - (29 - i) * dayMs,
value = (base * (0.4 + i * 0.02) + noise).toInt().coerceAtLeast(0)
)
}
return TrackStats(
trackId = trackId,
artist = entry?.artist ?: "Неизвестный исполнитель",
song = entry?.song ?: "Неизвестный трек",
album = sampleAlbum(trackId),
coverUrl = entry?.coverUrl,
releaseDate = sampleReleaseDate(trackId),
firstSeen = "2024-01-15",
totalPlays = entry?.plays ?: 12_000,
totalLikes = entry?.likes ?: 430,
isLiked = false,
currentRank = entry?.rank,
peakRank = (entry?.rank ?: 5).coerceAtMost(3),
stations = listOf(
StationPlays(1, "Европа Плюс", 3_200),
StationPlays(2, "Авторадио", 2_800),
StationPlays(3, "Радио Energy", 1_900)
),
playsTimeline = timeline(entry?.plays ?: 12_000),
likesTimeline = timeline(entry?.likes ?: 430)
)
}
private fun sampleAlbum(trackId: String): String? = when (trackId) {
"track_01" -> "Чёрный альбом"
"track_02" -> "Танцы на стёклах"
"track_03" -> "Dangerous"
"track_04" -> "Воздух"
"track_05" -> "÷ (Divide)"
else -> null
}
private fun sampleReleaseDate(trackId: String): String? = when (trackId) {
"track_01" -> "2023-09-22"
"track_02" -> "2022-06-15"
"track_03" -> "1991-11-26"
"track_04" -> "2023-03-10"
"track_05" -> "2017-03-03"
else -> "2024-01-01"
}
companion object {
// TODO: убрать превью, когда бэкенд отдаёт charts
val SAMPLE_CHARTS: List<ChartEntry> = listOf(
ChartEntry(1, "track_01", "Монеточка", "Каждый раз", null, 48_200, 12, 1_840, 2, ChartTrend.UP),
ChartEntry(2, "track_02", "Земфира", "Хочешь?", null, 43_100, 15, 2_100, 1, ChartTrend.DOWN),
ChartEntry(3, "track_03", "Michael Jackson", "Billie Jean", null, 41_500, 18, 3_200, null, ChartTrend.NEW),
ChartEntry(4, "track_04", "Баста", "Моя игра", null, 38_900, 11, 1_420, 5, ChartTrend.UP),
ChartEntry(5, "track_05", "Ed Sheeran", "Shape of You", null, 36_400, 20, 2_870, 4, ChartTrend.DOWN),
ChartEntry(6, "track_06", "Ленинград", "Экспонат", null, 34_700, 9, 980, 6, ChartTrend.SAME),
ChartEntry(7, "track_07", "Imagine Dragons", "Believer", null, 33_100, 16, 1_650, 8, ChartTrend.UP),
ChartEntry(8, "track_08", "Нервы", "Друг", null, 31_500, 7, 760, 7, ChartTrend.DOWN),
ChartEntry(9, "track_09", "The Weeknd", "Blinding Lights", null, 29_800, 22, 2_440, null, ChartTrend.NEW),
ChartEntry(10, "track_10", "Сплин", "Романс", null, 27_300, 10, 690, 10, ChartTrend.SAME),
ChartEntry(11, "track_11", "Dua Lipa", "Levitating", null, 25_600, 14, 1_110, 12, ChartTrend.UP),
ChartEntry(12, "track_12", "Кино", "Группа крови", null, 23_900, 8, 870, 11, ChartTrend.DOWN),
ChartEntry(13, "track_13", "Bruno Mars", "Uptown Funk", null, 22_100, 19, 1_320, null, ChartTrend.NEW),
ChartEntry(14, "track_14", "Мумий Тролль", "Невеста", null, 20_400, 6, 510, 14, ChartTrend.SAME),
ChartEntry(15, "track_15", "Coldplay", "Yellow", null, 18_700, 13, 780, 15, ChartTrend.SAME)
)
}
}
/** Расширение для случайного числа в диапазоне (используется в preview). */
private fun IntRange.random(): Int =
if (first >= last) first else first + (Math.random() * (last - first + 1)).toInt()