feat(android): liking a track downloads its audio into the Liked budget on Wi-Fi (#6308)

LikedAudioPinner downloads liked tracks' whole audio when "Keep liked
songs offline" is on: most recently liked first, one at a time, only
on an unmetered network while the server is reachable, and only while
the Liked budget has room for another track at CD bitrate, so it never
pushes out liked audio already on the phone. Tracks liked before this
ships are covered by the same pass. Any change (a like, an unlike, the
switch, the network) restarts the pass and cancels a download in flight.

Supporting changes:
- ConnectivityObserver.unmetered: the default network's NOT_METERED bit.
- Liked track ids are read newest first, and refreshIds stamps the
  server's newest-first order into likedAt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 15:23:49 -04:00
co-authored by Claude Opus 5.5
parent 7ec8a82871
commit ec1e59921b
5 changed files with 198 additions and 12 deletions
@@ -20,6 +20,7 @@ import com.fabledsword.minstrel.metadata.FreshnessSweeper
import com.fabledsword.minstrel.notifications.delivery.DeliveryLauncher
import com.fabledsword.minstrel.player.AudioPrefetcher
import com.fabledsword.minstrel.player.CoverPrefetcher
import com.fabledsword.minstrel.player.LikedAudioPinner
import com.fabledsword.minstrel.player.PlayEventsReporter
import com.fabledsword.minstrel.player.PlaybackErrorReporter
import com.fabledsword.minstrel.player.ResumeController
@@ -129,6 +130,13 @@ class MinstrelApplication :
*/
@Suppress("unused") @Inject lateinit var cacheBudgetSync: CacheBudgetSync
/**
* Same construct-the-singleton trick — LikedAudioPinner's init block
* downloads liked tracks into the Liked songs budget. Without this
* @Inject, liking a track would not keep it offline.
*/
@Suppress("unused") @Inject lateinit var likedAudioPinner: LikedAudioPinner
/**
* Same construct-the-singleton trick — AudioPrefetcher's init
* block subscribes to PlayerController.uiState +
@@ -10,12 +10,16 @@ import kotlinx.coroutines.flow.Flow
@Dao
interface CachedLikeDao {
/** All track IDs the user has liked. Audio-cache eviction uses this set. */
/**
* The user's liked track IDs, most recently liked first. The audio
* cache's Liked budget and the liked-audio downloads read this.
*/
@Query(
"SELECT entityId FROM cached_likes " +
"WHERE userId = :userId AND entityType = 'track'",
"WHERE userId = :userId AND entityType = 'track' " +
"ORDER BY likedAt DESC",
)
fun observeLikedTrackIds(userId: String): Flow<List<String>>
fun observeLikedTrackIdsNewestFirst(userId: String): Flow<List<String>>
@Query(
"SELECT entityId FROM cached_likes " +
@@ -74,6 +74,35 @@ class ConnectivityObserver @Inject constructor(
awaitClose { cm.unregisterNetworkCallback(callback) }
}.distinctUntilChanged()
/**
* Whether the network the device is using now is unmetered: Wi-Fi or
* Ethernet, but not mobile data or a hotspot Android marks metered.
* Downloads the user did not start (the liked-song downloads) wait for
* this.
*/
val unmetered: Flow<Boolean> = callbackFlow {
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onCapabilitiesChanged(
network: Network,
capabilities: NetworkCapabilities,
) {
trySend(capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED))
}
override fun onLost(network: Network) {
trySend(false)
}
}
cm.registerDefaultNetworkCallback(callback)
trySend(isActiveNetworkUnmetered())
awaitClose { cm.unregisterNetworkCallback(callback) }
}.distinctUntilChanged()
private fun isActiveNetworkUnmetered(): Boolean {
val caps = cm.activeNetwork?.let { cm.getNetworkCapabilities(it) }
return caps != null &&
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
}
private fun hasActiveNetwork(): Boolean {
val caps = cm.activeNetwork?.let { cm.getNetworkCapabilities(it) }
return caps != null &&
@@ -22,10 +22,12 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.datetime.Clock
import retrofit2.Retrofit
import retrofit2.create
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.milliseconds
/**
* Cache-first reads + offline-first writes for likes.
@@ -122,18 +124,22 @@ class LikesRepository @Inject constructor(
likeDao.observeLikedIdsOfType(currentUserId(), ENTITY_TRACK).map { it.toSet() }
/**
* The liked track ids of whoever is signed in, following sign-in and
* account changes. Emits nothing while signed out, so a long-lived
* collector never mistakes the anonymous empty set for "no likes".
* The liked track ids of whoever is signed in, most recently liked
* first, following sign-in and account changes. Emits nothing while
* signed out, so a long-lived collector never mistakes the anonymous
* empty set for "no likes".
*/
@OptIn(ExperimentalCoroutinesApi::class)
fun observeSignedInLikedTrackIds(): Flow<Set<String>> =
fun observeSignedInLikedTrackIdsNewestFirst(): Flow<List<String>> =
authController.currentUser
.map { it?.id }
.distinctUntilChanged()
.filterNotNull()
.flatMapLatest { userId -> likeDao.observeLikedIdsOfType(userId, ENTITY_TRACK) }
.map { it.toSet() }
.flatMapLatest { userId -> likeDao.observeLikedTrackIdsNewestFirst(userId) }
/** [observeSignedInLikedTrackIdsNewestFirst] as a set. */
fun observeSignedInLikedTrackIds(): Flow<Set<String>> =
observeSignedInLikedTrackIdsNewestFirst().map { it.toSet() }
/** One-shot snapshot of the liked track-id set — for the offline pool filter. */
suspend fun likedTrackIds(): Set<String> =
@@ -184,14 +190,21 @@ class LikesRepository @Inject constructor(
* device would stay on the Liked tab forever. The replaceAllForUser
* DAO method runs a transactional delete-then-insert so the local
* set is exactly what the server reports.
*
* The server lists each kind most recently liked first but sends no
* times, so the rows are stamped a millisecond apart in that order;
* `likedAt` then sorts the way the server does.
*/
suspend fun refreshIds() {
val uid = currentUserId()
val wire = api.ids()
val now = Clock.System.now()
fun rowsOf(type: String, ids: List<String>) =
ids.mapIndexed { i, id -> CachedLikeEntity(uid, type, id, likedAt = now - i.milliseconds) }
val rows = mutableListOf<CachedLikeEntity>()
rows += wire.artistIds.map { CachedLikeEntity(uid, ENTITY_ARTIST, it) }
rows += wire.albumIds.map { CachedLikeEntity(uid, ENTITY_ALBUM, it) }
rows += wire.trackIds.map { CachedLikeEntity(uid, ENTITY_TRACK, it) }
rows += rowsOf(ENTITY_ARTIST, wire.artistIds)
rows += rowsOf(ENTITY_ALBUM, wire.albumIds)
rows += rowsOf(ENTITY_TRACK, wire.trackIds)
likeDao.replaceAllForUser(uid, rows)
}
@@ -0,0 +1,132 @@
package com.fabledsword.minstrel.player
import android.net.Uri
import androidx.media3.common.C
import androidx.media3.datasource.DataSpec
import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.datasource.cache.CacheWriter
import androidx.media3.datasource.cache.ContentMetadata
import androidx.media3.datasource.okhttp.OkHttpDataSource
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.cache.audiocache.TwoBucketCacheEvictor
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
import com.fabledsword.minstrel.connectivity.ConnectivityObserver
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.ServerHealth
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.library.data.toDomain
import com.fabledsword.minstrel.likes.data.LikesRepository
import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.shared.resolveServerUrl
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible
import okhttp3.OkHttpClient
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
// The Liked budget must have room for a track at CD bitrate (1411 kbps)
// before one is downloaded, so a download never pushes out liked audio that
// is already on the phone. A track of unknown length is assumed to be ten
// minutes.
private const val CD_BYTES_PER_SECOND = 176_400L
private const val UNKNOWN_DURATION_SEC = 600
/**
* Keeps liked songs on the phone: when "Keep liked songs offline" is on,
* every liked track's whole audio is downloaded into the cache, where
* [TwoBucketCacheEvictor] counts it against the Liked songs budget.
*
* - Most recently liked first, one track at a time, so the newest likes get
* the room and the server is never asked for more than one file at once.
* - Only on an unmetered network (Wi-Fi or Ethernet) while the server is
* reachable. A like made on mobile data downloads once the phone is back
* on Wi-Fi.
* - Stops when the Liked budget cannot fit another track. Liked tracks
* played after that are still cached as they stream.
* - Any change (a like, an unlike, the switch, the network) restarts the
* pass, cancelling a download in flight. Tracks already fully on the
* phone are skipped without touching the network, and a track whose
* download failed is tried again on the next pass.
*
* Downloads are keyed by track id exactly like playback and
* [AudioPrefetcher], so the player reads what this writes.
*
* Eager-constructed via the construct-the-singleton trick in
* `MinstrelApplication`.
*/
@Singleton
class LikedAudioPinner @Inject constructor(
@ApplicationScope scope: CoroutineScope,
likes: LikesRepository,
authStore: AuthStore,
connectivity: ConnectivityObserver,
serverHealth: NetworkStatusController,
private val playerFactory: PlayerFactory,
private val evictor: TwoBucketCacheEvictor,
private val trackDao: CachedTrackDao,
okHttpClient: OkHttpClient,
) {
private val cacheDataSourceFactory: CacheDataSource.Factory =
CacheDataSource.Factory()
.setCache(playerFactory.simpleCache)
.setUpstreamDataSourceFactory(OkHttpDataSource.Factory(okHttpClient))
private data class Pass(val likedNewestFirst: List<String>, val enabled: Boolean, val canDownload: Boolean)
init {
scope.launch(Dispatchers.IO) {
combine(
likes.observeSignedInLikedTrackIdsNewestFirst(),
authStore.cacheSettings.map { it.cacheLikedTracks },
connectivity.unmetered,
serverHealth.state.map { it == ServerHealth.Healthy || it == ServerHealth.Unstable },
) { liked, enabled, unmetered, reachable ->
Pass(liked, enabled, unmetered && reachable)
}
.distinctUntilChanged()
.collectLatest { pass ->
if (pass.enabled && pass.canDownload) pinAll(pass.likedNewestFirst)
}
}
}
private suspend fun pinAll(likedNewestFirst: List<String>) {
for (trackId in likedNewestFirst) {
val track = downloadable(trackId)
if (track != null) {
val durationSec = track.durationSec.takeIf { it > 0 } ?: UNKNOWN_DURATION_SEC
if (evictor.usage().likedRoomBytes < durationSec * CD_BYTES_PER_SECOND) return
runCatching { runInterruptible { download(track.id, track.streamUrl) } }
.onFailure { Timber.d(it, "liked audio: download of %s failed; next pass retries", track.id) }
}
}
}
/** The track, when it still needs downloading and the server can serve it. */
private suspend fun downloadable(trackId: String): TrackRef? {
if (isFullyCached(trackId)) return null
val track = trackDao.getById(trackId)?.toDomain()
return track?.takeIf { !it.unavailable && it.streamUrl.isNotBlank() }
}
private fun isFullyCached(trackId: String): Boolean {
val cache = playerFactory.simpleCache
val length = ContentMetadata.getContentLength(cache.getContentMetadata(trackId))
return length != C.LENGTH_UNSET.toLong() && cache.isCached(trackId, 0, length)
}
private fun download(trackId: String, streamUrl: String) {
val dataSpec = DataSpec.Builder()
.setUri(Uri.parse(resolveServerUrl(streamUrl) ?: streamUrl))
.setKey(trackId)
.build()
CacheWriter(cacheDataSourceFactory.createDataSource(), dataSpec, null, null).cache()
}
}