package com.radiola.data.remote import com.radiola.data.remote.dto.SubmitCoverDto import com.radiola.domain.model.Track import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.selects.select import java.util.Collections import javax.inject.Inject import javax.inject.Singleton /** * Клиентское обогащение обложек. Серверный IP забанен Apple (429), поэтому * iTunes-поиск делаем С УСТРОЙСТВА пользователя (его IP не забанен), а найденную * ссылку на арт шлём на наш бэкенд — он скачивает её и кладёт WebP к себе. * Дальше обложка приходит ВСЕМ через /now-playing. * * Две дорожки: приоритетная (трек, который слушают прямо сейчас — обрабатывается * первой) и общая (остальные now-playing). Дедуп + троттлинг, чтобы не * злоупотреблять iTunes с устройства. */ @Singleton class CoverEnrichmentManager @Inject constructor( private val itunesApi: ItunesApi, private val radiolaApi: RadiolaApi, ) { private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) // Что уже поставлено в общую очередь (чтобы не дублировать пачку now-playing). private val enqueued = Collections.synchronizedSet(HashSet()) // Что уже обработали (чтобы приоритет и общая дорожка не делали двойную работу). private val processed = Collections.synchronizedSet(HashSet()) private val priority = Channel(Channel.UNLIMITED) private val normal = Channel(Channel.UNLIMITED) init { scope.launch { while (true) { val track = priority.tryReceive().getOrNull() ?: select { priority.onReceive { it } normal.onReceive { it } } processOne(track) delay(THROTTLE_MS) } } } /** Поставить пачку now-playing-треков без обложки в общую очередь. */ fun enqueue(tracks: Collection) { for (t in tracks) { if (!isEnrichable(t)) continue if (enqueued.add(normKey(t))) normal.trySend(t) } } /** Трек, который слушают прямо сейчас — вперёд очереди (вызывать при смене трека). */ fun enqueuePriority(track: Track?) { if (track == null || !isEnrichable(track)) return priority.trySend(track) } private fun isEnrichable(t: Track): Boolean = t.coverUrl.isNullOrBlank() && t.artist.isNotBlank() && t.song.isNotBlank() private suspend fun processOne(track: Track) { val key = normKey(track) if (!processed.add(key)) return // уже обрабатывали (другая дорожка) try { val term = clean("${track.artist} ${track.song}") if (term.isBlank()) return val art = itunesApi.search(term).results.firstOrNull()?.artworkUrl100 ?: return // 100x100 → 600x600 (источник покрупнее, сервер всё равно ресайзит) val big = art.replace(Regex("/\\d+x\\d+bb\\."), "/600x600bb.") val resp = radiolaApi.submitCover(SubmitCoverDto(track.artist, track.song, big)) android.util.Log.d("CoverEnrich", "submit '${track.artist} - ${track.song}' -> ${resp.coverUrl}") } catch (_: Exception) { // сеть/429/таймаут — не критично; снимаем метку, чтобы могли попробовать позже processed.remove(key) } } private fun normKey(t: Track): String = "${t.artist.trim().lowercase()}|${t.song.trim().lowercase()}" /** Убираем суффиксы «(Original Mix)», «[... Dub]» и пунктуацию — лучше матчит. */ private fun clean(s: String): String = s .replace(Regex("\\([^)]*\\)|\\[[^\\]]*\\]"), " ") .replace(Regex("[^\\p{L}\\p{N}]+"), " ") .replace(Regex("\\s+"), " ") .trim() companion object { private const val THROTTLE_MS = 800L } }