fix: cached track audio not being invalidated causing alternative track source to not being played

This commit is contained in:
Kingkor Roy Tirtho 2026-08-14 13:15:34 +06:00
parent b6615bdbc8
commit 16e2a743a5
9 changed files with 62 additions and 35 deletions

View File

@ -259,12 +259,16 @@ class DeviceAudioPlayerQueue(
return
}
val queue = queueFlow.value
// Using separate current index entry as the current track's source url has changed so it won't match
val index = queue.indexOfFirst { entry ->
when {
entry is QueueEntry.StreamingTrack && current is QueueEntry.StreamingTrack ->
when (entry) {
is QueueEntry.StreamingTrack if current is QueueEntry.StreamingTrack ->
entry.track.id == current.track.id
entry is QueueEntry.LocalTrack && current is QueueEntry.LocalTrack ->
is QueueEntry.LocalTrack if current is QueueEntry.LocalTrack ->
entry.url == current.url && entry.name == current.name
else -> false
}
}

View File

@ -27,6 +27,7 @@ import dev.krtirtho.spotube.core.discord.DiscordRpcService
import dev.krtirtho.spotube.core.navigation.navigationModule
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
import dev.krtirtho.spotube.core.server.AlternativeTracksRepository
import dev.krtirtho.spotube.core.server.CacheManager
import dev.krtirtho.spotube.core.server.LocalServer
import dev.krtirtho.spotube.core.server.MatchedTracksRepository
import dev.krtirtho.spotube.core.server.StreamingUrlRepository
@ -190,6 +191,7 @@ val sharedModules = module {
}
singleOf(::MatchedTracksRepository)
singleOf(::StreamingUrlRepository)
singleOf(::CacheManager)
singleOf(::AlternativeTracksRepository)
singleOf(::LocalServer) withOptions {
createdAtStart()

View File

@ -29,6 +29,7 @@ class AlternativeTracksRepository(
private val matchedTracksRepository: MatchedTracksRepository,
private val streamingUrlRepository: StreamingUrlRepository,
private val audioPlayerQueue: AudioPlayerQueue,
private val cacheManager: CacheManager,
) : KoinComponent {
private val logger by injectLogger<AlternativeTracksRepository>()
@ -78,6 +79,7 @@ class AlternativeTracksRepository(
matchedTracksRepository.saveTrackSource(track, basic)
streamingUrlRepository.invalidateCachedStreamUrl(trackId)
streamingUrlRepository.invalidateCachedAlternatives(trackId)
cacheManager.invalidateCacheEntry(trackId)
audioPlayerQueue.reloadCurrent()
logger.d { "Alternative source selection complete for track $trackId" }
}

View File

@ -19,6 +19,7 @@ package dev.krtirtho.spotube.core.server
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.paths.Paths
import dev.krtirtho.spotube.modules.settings.SettingsRepository
import io.ktor.http.ContentType
import io.ktor.http.Headers
import io.ktor.http.HeadersBuilder
@ -40,12 +41,12 @@ import okio.buffer
import okio.use
@Serializable
internal data class CacheIndex(
data class CacheIndex(
val entries: List<CacheEntry> = emptyList()
)
@Serializable
internal data class CacheEntry(
data class CacheEntry(
val trackId: String,
val filename: String,
val sizeBytes: Long,
@ -53,18 +54,26 @@ internal data class CacheEntry(
val contentType: String = "application/octet-stream",
)
internal class CacheManager(
class CacheManager(
private val paths: Paths,
private val resolveCacheFolder: () -> String?,
private val resolveSizeLimitMB: () -> Long,
private val fileSystem: FileSystem = FileSystem.SYSTEM,
private val cacheMutex: Mutex = Mutex(),
private val json: Json = Json { ignoreUnknownKeys = true },
private val settingsRepository: SettingsRepository,
) {
private val fileSystem: FileSystem = FileSystem.SYSTEM
private val cacheMutex: Mutex = Mutex()
private val json: Json = Json { ignoreUnknownKeys = true }
companion object {
private val cacheIndexFileName = "cache_index.json".toPath()
}
private fun resolveCacheFolder(): String? {
return settingsRepository.userSettings.value.cacheFolder
}
private fun resolveSizeLimitMB(): Long {
return settingsRepository.userSettings.value.cacheSizeLimitMB
}
fun resolveCacheDir(): Path {
val folder = resolveCacheFolder()
return if (!folder.isNullOrBlank()) {
@ -101,6 +110,18 @@ internal class CacheManager(
return if (fileSystem.exists(filePath)) filePath to entry else null
}
suspend fun invalidateCacheEntry(trackId: String) {
cacheMutex.withLock {
val index = readCacheIndex()
val entry = index.entries.firstOrNull { it.trackId == trackId } ?: return
val filePath = resolveCacheDir() / entry.filename.toPath()
if (fileSystem.exists(filePath)) {
fileSystem.delete(filePath)
}
writeCacheIndex(index.copy(entries = index.entries.filter { it.trackId != trackId }))
}
}
fun resolveCacheFilename(track: MetadataTrack, contentType: String?): String {
val artists = track.artists.joinToString(", ") { it.name }.sanitizeFilenamePart()
val title = track.title.sanitizeFilenamePart()

View File

@ -19,7 +19,6 @@ package dev.krtirtho.spotube.core.server
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.di.injectLogger
import dev.krtirtho.spotube.core.paths.Paths
import dev.krtirtho.spotube.modules.settings.SettingsViewModel
import io.ktor.client.HttpClient
import io.ktor.http.HttpMethod
@ -52,10 +51,10 @@ import kotlinx.coroutines.sync.withLock
import org.koin.core.component.KoinComponent
class LocalServer(
private val paths: Paths,
settingsViewModel: SettingsViewModel,
private val streamingUrlRepository: StreamingUrlRepository,
private val audioPlayerQueue: AudioPlayerQueue,
private val cacheManager: CacheManager,
) : KoinComponent {
val logger by injectLogger<LocalServer>()
@ -76,14 +75,6 @@ class LocalServer(
}.stateIn(scope, SharingStarted.WhileSubscribed(5_000), null)
private val cachedCacheEnabled = MutableStateFlow(false)
private val cachedCacheFolder = MutableStateFlow<String?>(null)
private val cachedCacheSizeLimitMB = MutableStateFlow(0L)
private val cacheManager = CacheManager(
paths = paths,
resolveCacheFolder = { cachedCacheFolder.value },
resolveSizeLimitMB = { cachedCacheSizeLimitMB.value },
)
private val streamProxy by lazy {
StreamProxy(
@ -94,7 +85,6 @@ class LocalServer(
isCachingEnabled = { cachedCacheEnabled.value },
activePort = { activePort.value },
scope = scope,
logger = logger,
)
}
@ -118,8 +108,6 @@ class LocalServer(
.collect { settings ->
if (settings != null) {
cachedCacheEnabled.value = settings.enableMusicCaching
cachedCacheFolder.value = settings.cacheFolder
cachedCacheSizeLimitMB.value = settings.cacheSizeLimitMB
}
}
}

View File

@ -21,6 +21,7 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.di.injectLogger
import io.ktor.client.HttpClient
import io.ktor.client.request.request
import io.ktor.client.statement.HttpResponse
@ -51,6 +52,7 @@ import okio.Path.Companion.toPath
import okio.SYSTEM
import okio.buffer
import okio.use
import org.koin.core.component.KoinComponent
internal class StreamProxy(
private val httpClient: HttpClient,
@ -60,8 +62,9 @@ internal class StreamProxy(
private val isCachingEnabled: () -> Boolean,
private val activePort: () -> Int?,
private val scope: CoroutineScope,
private val logger: co.touchlab.kermit.Logger,
) {
): KoinComponent {
val logger by injectLogger<StreamProxy>()
companion object {
private const val HOST = "127.0.0.1"
}

View File

@ -20,6 +20,10 @@ package dev.krtirtho.spotube.modules.settings
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import dev.krtirtho.spotube.core.db.Database
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.*
import kotlinx.serialization.json.Json
@ -28,14 +32,20 @@ class SettingsRepository(private val database: Database) {
private val SETTINGS_KEY = stringPreferencesKey("user_settings")
}
val userSettings: Flow<UserSettings> = database.settingsDataStore.data.map { prefs ->
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val userSettings: StateFlow<UserSettings> = database.settingsDataStore.data.map { prefs ->
val json = prefs[SETTINGS_KEY]
if (json != null) {
Json.decodeFromString<UserSettings>(json as String)
} else {
UserSettings() // Default value
}
}
}.stateIn(
scope,
started = SharingStarted.Eagerly,
initialValue = UserSettings() // Default value
)
suspend fun updateSettings(newSettings: UserSettings) {
database.settingsDataStore.edit { prefs ->

View File

@ -32,11 +32,7 @@ interface SettingsProvider {
class SettingsViewModel(
private val repository: SettingsRepository
) : ViewModel(), SettingsProvider {
override val settingsState: StateFlow<UserSettings?> = repository.userSettings.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5000),
null,
)
override val settingsState = repository.userSettings
fun updateSettings(transform: UserSettings.() -> UserSettings) {
viewModelScope.launch {

View File

@ -95,8 +95,9 @@ class AlternativeTrackContentViewModel(
}
fun selectAlternative(source: AudioSource) {
val currentTrack = (audioPlayerQueue.currentQueueEntryFlow.value as? QueueEntry.StreamingTrack)?.track
if (currentTrack == null) return
val currentTrack =
(audioPlayerQueue.currentQueueEntryFlow.value as? QueueEntry.StreamingTrack)?.track
?: return
viewModelScope.launch {
alternativeTracksRepository.selectAlternative(currentTrack, source)
activeSourceIdFlow.value = source.id