feat(app): кнопка «Распознать трек» (Shazam) + история распознанных

- кнопка распознавания в плеере: видна только на музыкальных станциях без
  метаданных эфира (track == null), показывает спиннер и результат через Toast
- распознанный трек отображается в плеере и пишется в ОТДЕЛЬНУЮ историю
  распознанных (не дублируется в историю эфирных треков — гейт по ключу)
- экран Истории: переключатель «Треки эфира | Распознанные», два списка
- Room: таблица recognized_track (миграция 7→8), DAO/репозиторий
- ShazamRepository → POST /shazam/recognize/{stationId}, маппинг 503/400 в текст
- MusicGenres.isMusicStation — клиентский гейт (синхронизирован с бэкендом)
- bump backend submodule (модуль shazam)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nk
2026-06-07 18:38:17 +03:00
parent 251809df33
commit 69682268f3
16 changed files with 371 additions and 28 deletions

View File

@@ -5,11 +5,13 @@ import androidx.room.RoomDatabase
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.radiola.data.local.dao.AlarmDao
import com.radiola.data.local.dao.RecognizedTrackDao
import com.radiola.data.local.dao.RecordingDao
import com.radiola.data.local.dao.StationDao
import com.radiola.data.local.dao.TagDao
import com.radiola.data.local.dao.TrackHistoryDao
import com.radiola.data.local.entity.AlarmEntity
import com.radiola.data.local.entity.RecognizedTrackEntity
import com.radiola.data.local.entity.RecordingEntity
import com.radiola.data.local.entity.StationEntity
import com.radiola.data.local.entity.TagEntity
@@ -78,9 +80,27 @@ val MIGRATION_6_7 = object : Migration(6, 7) {
}
}
// Добавляем таблицу истории распознанных треков (Shazam)
val MIGRATION_7_8 = object : Migration(7, 8) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS recognized_track (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
artist TEXT NOT NULL,
song TEXT NOT NULL,
stationName TEXT NOT NULL,
coverUrl TEXT,
timestamp INTEGER NOT NULL
)
""".trimIndent()
)
}
}
@Database(
entities = [StationEntity::class, TrackHistoryEntity::class, TagEntity::class, RecordingEntity::class, AlarmEntity::class],
version = 7
entities = [StationEntity::class, TrackHistoryEntity::class, TagEntity::class, RecordingEntity::class, AlarmEntity::class, RecognizedTrackEntity::class],
version = 8
)
abstract class AppDatabase : RoomDatabase() {
abstract fun stationDao(): StationDao
@@ -88,4 +108,5 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun tagDao(): TagDao
abstract fun recordingDao(): RecordingDao
abstract fun alarmDao(): AlarmDao
abstract fun recognizedTrackDao(): RecognizedTrackDao
}

View File

@@ -0,0 +1,20 @@
package com.radiola.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import com.radiola.data.local.entity.RecognizedTrackEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface RecognizedTrackDao {
@Query("SELECT * FROM recognized_track ORDER BY timestamp DESC LIMIT 200")
fun getAll(): Flow<List<RecognizedTrackEntity>>
@Insert
suspend fun insert(track: RecognizedTrackEntity)
@Query("DELETE FROM recognized_track WHERE id NOT IN (SELECT id FROM recognized_track ORDER BY timestamp DESC LIMIT 200)")
suspend fun cleanupOld()
}

View File

@@ -0,0 +1,14 @@
package com.radiola.data.local.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "recognized_track")
data class RecognizedTrackEntity(
@PrimaryKey(autoGenerate = true) val id: Int = 0,
val artist: String,
val song: String,
val stationName: String,
val coverUrl: String?,
val timestamp: Long
)

View File

@@ -8,6 +8,7 @@ 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
import com.radiola.data.remote.dto.RecognizeResponseDto
import com.radiola.data.remote.dto.TrackStatsDto
import com.radiola.data.remote.dto.UserSettingsDto
import kotlinx.serialization.json.JsonObject
@@ -30,6 +31,10 @@ interface RadiolaApi {
@GET("now-playing")
suspend fun getNowPlaying(): List<BackendNowPlayingDto>
// Распознавание играющего трека через Shazam (бэкенд сам тянет аудио из потока).
@POST("shazam/recognize/{stationId}")
suspend fun recognizeTrack(@Path("stationId") stationId: Int): RecognizeResponseDto
// Сабмит обложки, найденной клиентом в iTunes (см. CoverEnrichmentManager).
@POST("covers/submit")
suspend fun submitCover(@Body dto: com.radiola.data.remote.dto.SubmitCoverDto): com.radiola.data.remote.dto.SubmitCoverResponse

View File

@@ -0,0 +1,12 @@
package com.radiola.data.remote.dto
import kotlinx.serialization.Serializable
@Serializable
data class RecognizeResponseDto(
val matched: Boolean,
val artist: String? = null,
val song: String? = null,
val coverUrl: String? = null,
val album: String? = null
)

View File

@@ -0,0 +1,34 @@
package com.radiola.data.repository
import com.radiola.data.local.AppDatabase
import com.radiola.data.local.entity.RecognizedTrackEntity
import com.radiola.domain.model.Track
import com.radiola.domain.repository.RecognizedTrackRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
class RecognizedTrackRepositoryImpl @Inject constructor(
private val db: AppDatabase
) : RecognizedTrackRepository {
override fun getHistory(): Flow<List<Track>> =
db.recognizedTrackDao().getAll().map { list -> list.map { it.toDomain() } }
override suspend fun addTrack(track: Track) {
db.recognizedTrackDao().insert(
RecognizedTrackEntity(
artist = track.artist,
song = track.song,
stationName = track.stationName,
coverUrl = track.coverUrl,
timestamp = System.currentTimeMillis()
)
)
db.recognizedTrackDao().cleanupOld()
}
private fun RecognizedTrackEntity.toDomain(): Track = Track(
artist = artist, song = song, coverUrl = coverUrl, stationName = stationName
)
}

View File

@@ -0,0 +1,40 @@
package com.radiola.data.repository
import com.radiola.data.remote.RadiolaApi
import com.radiola.domain.model.Track
import com.radiola.domain.repository.RecognizeResult
import com.radiola.domain.repository.ShazamRepository
import retrofit2.HttpException
import javax.inject.Inject
class ShazamRepositoryImpl @Inject constructor(
private val api: RadiolaApi
) : ShazamRepository {
override suspend fun recognize(stationId: Int, stationName: String): RecognizeResult {
return try {
val res = api.recognizeTrack(stationId)
if (res.matched && !res.artist.isNullOrBlank() && !res.song.isNullOrBlank()) {
RecognizeResult.Found(
Track(
artist = res.artist,
song = res.song,
coverUrl = res.coverUrl,
stationName = stationName
)
)
} else {
RecognizeResult.NotFound
}
} catch (e: HttpException) {
val msg = when (e.code()) {
503 -> "Распознавание временно недоступно"
400 -> "На этой станции нет музыки"
else -> "Не удалось распознать трек"
}
RecognizeResult.Error(msg)
} catch (e: Exception) {
RecognizeResult.Error("Нет связи с сервером")
}
}
}