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() + } +}