DLNA renderers, app updates told like Librarian, acquisition without Lidarr, Collection and admin rework (M502–M505, M542) #156
@@ -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