feat(covers): приоритет играющего трека + троттл 0.8с
Обложки наливались общей очередью (1.5с) — играющий трек ждал свою очередь. Добавлена приоритетная дорожка: трек, который слушают сейчас, обогащается первым (PlayerViewModel → NowPlayingRepository.enrichCoverNow). Троттл общей очереди ускорен 1.5с→0.8с. Дедуп разнесён на enqueued/processed, чтобы дорожки не дублировали работу. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@ import kotlinx.coroutines.SupervisorJob
|
|||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.selects.select
|
||||||
import java.util.Collections
|
import java.util.Collections
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
@@ -16,8 +17,11 @@ import javax.inject.Singleton
|
|||||||
* Клиентское обогащение обложек. Серверный IP забанен Apple (429), поэтому
|
* Клиентское обогащение обложек. Серверный IP забанен Apple (429), поэтому
|
||||||
* iTunes-поиск делаем С УСТРОЙСТВА пользователя (его IP не забанен), а найденную
|
* iTunes-поиск делаем С УСТРОЙСТВА пользователя (его IP не забанен), а найденную
|
||||||
* ссылку на арт шлём на наш бэкенд — он скачивает её и кладёт WebP к себе.
|
* ссылку на арт шлём на наш бэкенд — он скачивает её и кладёт WebP к себе.
|
||||||
* Дальше обложка приходит ВСЕМ через /now-playing. Дедуп по треку (в рамках
|
* Дальше обложка приходит ВСЕМ через /now-playing.
|
||||||
* сессии) + троттлинг, чтобы не злоупотреблять iTunes с устройства.
|
*
|
||||||
|
* Две дорожки: приоритетная (трек, который слушают прямо сейчас — обрабатывается
|
||||||
|
* первой) и общая (остальные now-playing). Дедуп + троттлинг, чтобы не
|
||||||
|
* злоупотреблять iTunes с устройства.
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class CoverEnrichmentManager @Inject constructor(
|
class CoverEnrichmentManager @Inject constructor(
|
||||||
@@ -25,28 +29,47 @@ class CoverEnrichmentManager @Inject constructor(
|
|||||||
private val radiolaApi: RadiolaApi,
|
private val radiolaApi: RadiolaApi,
|
||||||
) {
|
) {
|
||||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
private val seen = Collections.synchronizedSet(HashSet<String>())
|
// Что уже поставлено в общую очередь (чтобы не дублировать пачку now-playing).
|
||||||
private val queue = Channel<Track>(Channel.UNLIMITED)
|
private val enqueued = Collections.synchronizedSet(HashSet<String>())
|
||||||
|
// Что уже обработали (чтобы приоритет и общая дорожка не делали двойную работу).
|
||||||
|
private val processed = Collections.synchronizedSet(HashSet<String>())
|
||||||
|
|
||||||
|
private val priority = Channel<Track>(Channel.UNLIMITED)
|
||||||
|
private val normal = Channel<Track>(Channel.UNLIMITED)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
for (track in queue) {
|
while (true) {
|
||||||
|
val track = priority.tryReceive().getOrNull() ?: select {
|
||||||
|
priority.onReceive { it }
|
||||||
|
normal.onReceive { it }
|
||||||
|
}
|
||||||
processOne(track)
|
processOne(track)
|
||||||
delay(THROTTLE_MS) // не долбить iTunes с устройства
|
delay(THROTTLE_MS)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Поставить now-playing-треки без обложки в очередь обогащения. */
|
/** Поставить пачку now-playing-треков без обложки в общую очередь. */
|
||||||
fun enqueue(tracks: Collection<Track>) {
|
fun enqueue(tracks: Collection<Track>) {
|
||||||
for (t in tracks) {
|
for (t in tracks) {
|
||||||
if (!t.coverUrl.isNullOrBlank()) continue
|
if (!isEnrichable(t)) continue
|
||||||
if (t.artist.isBlank() || t.song.isBlank()) continue
|
if (enqueued.add(normKey(t))) normal.trySend(t)
|
||||||
if (seen.add(normKey(t))) queue.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) {
|
private suspend fun processOne(track: Track) {
|
||||||
|
val key = normKey(track)
|
||||||
|
if (!processed.add(key)) return // уже обрабатывали (другая дорожка)
|
||||||
try {
|
try {
|
||||||
val term = clean("${track.artist} ${track.song}")
|
val term = clean("${track.artist} ${track.song}")
|
||||||
if (term.isBlank()) return
|
if (term.isBlank()) return
|
||||||
@@ -56,7 +79,8 @@ class CoverEnrichmentManager @Inject constructor(
|
|||||||
val resp = radiolaApi.submitCover(SubmitCoverDto(track.artist, track.song, big))
|
val resp = radiolaApi.submitCover(SubmitCoverDto(track.artist, track.song, big))
|
||||||
android.util.Log.d("CoverEnrich", "submit '${track.artist} - ${track.song}' -> ${resp.coverUrl}")
|
android.util.Log.d("CoverEnrich", "submit '${track.artist} - ${track.song}' -> ${resp.coverUrl}")
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
// сеть/429/таймаут — не критично, добёрут другие клиенты или попытки
|
// сеть/429/таймаут — не критично; снимаем метку, чтобы могли попробовать позже
|
||||||
|
processed.remove(key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +95,6 @@ class CoverEnrichmentManager @Inject constructor(
|
|||||||
.trim()
|
.trim()
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val THROTTLE_MS = 1500L
|
private const val THROTTLE_MS = 800L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,4 +54,6 @@ class NowPlayingRepositoryImpl @Inject constructor(
|
|||||||
Result.failure(e)
|
Result.failure(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun enrichCoverNow(track: Track) = coverEnrichment.enqueuePriority(track)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,4 +7,6 @@ interface NowPlayingRepository {
|
|||||||
fun getNowPlaying(stationId: Int): Flow<Track?>
|
fun getNowPlaying(stationId: Int): Flow<Track?>
|
||||||
fun getAllNowPlaying(): Flow<Map<Int, Track>>
|
fun getAllNowPlaying(): Flow<Map<Int, Track>>
|
||||||
suspend fun refreshNowPlaying(): Result<Unit>
|
suspend fun refreshNowPlaying(): Result<Unit>
|
||||||
|
/** Обогатить обложку трека приоритетно (тот, что слушают прямо сейчас). */
|
||||||
|
fun enrichCoverNow(track: Track)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,6 +125,10 @@ class PlayerViewModel @Inject constructor(
|
|||||||
.collect { track ->
|
.collect { track ->
|
||||||
if (track != null) {
|
if (track != null) {
|
||||||
_currentTrack.value = track
|
_currentTrack.value = track
|
||||||
|
// Нет обложки — обогащаем приоритетно (играет прямо сейчас).
|
||||||
|
if (track.coverUrl.isNullOrBlank()) {
|
||||||
|
nowPlayingRepository.enrichCoverNow(track)
|
||||||
|
}
|
||||||
playerController.updateMetadata(
|
playerController.updateMetadata(
|
||||||
track.song,
|
track.song,
|
||||||
track.artist,
|
track.artist,
|
||||||
|
|||||||
Reference in New Issue
Block a user