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:
@@ -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 +
|
||||
|
||||
+7
-3
@@ -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 " +
|
||||
|
||||
+29
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user