DLNA renderers, app updates told like Librarian, acquisition without Lidarr, Collection and admin rework (M502–M505, M542) #156

Merged
bvandeusen merged 117 commits from dev into main 2026-10-26 12:32:33 -04:00
5 changed files with 198 additions and 12 deletions
Showing only changes of commit ec1e59921b - Show all commits
@@ -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()
}
}