From ec1e59921b6ad28b9851a6cb35546923236c45d0 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 23 Oct 2026 15:23:49 -0400 Subject: [PATCH] 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 --- .../minstrel/MinstrelApplication.kt | 8 ++ .../minstrel/cache/db/dao/CachedLikeDao.kt | 10 +- .../connectivity/ConnectivityObserver.kt | 29 ++++ .../minstrel/likes/data/LikesRepository.kt | 31 ++-- .../minstrel/player/LikedAudioPinner.kt | 132 ++++++++++++++++++ 5 files changed, 198 insertions(+), 12 deletions(-) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/player/LikedAudioPinner.kt diff --git a/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt b/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt index 0ca637e9..7cb6116f 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt @@ -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 + diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedLikeDao.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedLikeDao.kt index 68e43ad1..5b3e2ed8 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedLikeDao.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedLikeDao.kt @@ -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> + fun observeLikedTrackIdsNewestFirst(userId: String): Flow> @Query( "SELECT entityId FROM cached_likes " + diff --git a/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ConnectivityObserver.kt b/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ConnectivityObserver.kt index ee4ccc4e..bf6ea705 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ConnectivityObserver.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ConnectivityObserver.kt @@ -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 = 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 && diff --git a/android/app/src/main/java/com/fabledsword/minstrel/likes/data/LikesRepository.kt b/android/app/src/main/java/com/fabledsword/minstrel/likes/data/LikesRepository.kt index 23f4dc36..05adf9cc 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/likes/data/LikesRepository.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/likes/data/LikesRepository.kt @@ -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> = + fun observeSignedInLikedTrackIdsNewestFirst(): Flow> = 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> = + observeSignedInLikedTrackIdsNewestFirst().map { it.toSet() } /** One-shot snapshot of the liked track-id set — for the offline pool filter. */ suspend fun likedTrackIds(): Set = @@ -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) = + ids.mapIndexed { i, id -> CachedLikeEntity(uid, type, id, likedAt = now - i.milliseconds) } val rows = mutableListOf() - 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) } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/LikedAudioPinner.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/LikedAudioPinner.kt new file mode 100644 index 00000000..8c507b8c --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/LikedAudioPinner.kt @@ -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, 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) { + 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() + } +}