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 return
} }
val queue = queueFlow.value 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 -> val index = queue.indexOfFirst { entry ->
when { when (entry) {
entry is QueueEntry.StreamingTrack && current is QueueEntry.StreamingTrack -> is QueueEntry.StreamingTrack if current is QueueEntry.StreamingTrack ->
entry.track.id == current.track.id 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 entry.url == current.url && entry.name == current.name
else -> false 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.navigation.navigationModule
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
import dev.krtirtho.spotube.core.server.AlternativeTracksRepository 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.LocalServer
import dev.krtirtho.spotube.core.server.MatchedTracksRepository import dev.krtirtho.spotube.core.server.MatchedTracksRepository
import dev.krtirtho.spotube.core.server.StreamingUrlRepository import dev.krtirtho.spotube.core.server.StreamingUrlRepository
@ -190,6 +191,7 @@ val sharedModules = module {
} }
singleOf(::MatchedTracksRepository) singleOf(::MatchedTracksRepository)
singleOf(::StreamingUrlRepository) singleOf(::StreamingUrlRepository)
singleOf(::CacheManager)
singleOf(::AlternativeTracksRepository) singleOf(::AlternativeTracksRepository)
singleOf(::LocalServer) withOptions { singleOf(::LocalServer) withOptions {
createdAtStart() createdAtStart()

View File

@ -29,6 +29,7 @@ class AlternativeTracksRepository(
private val matchedTracksRepository: MatchedTracksRepository, private val matchedTracksRepository: MatchedTracksRepository,
private val streamingUrlRepository: StreamingUrlRepository, private val streamingUrlRepository: StreamingUrlRepository,
private val audioPlayerQueue: AudioPlayerQueue, private val audioPlayerQueue: AudioPlayerQueue,
private val cacheManager: CacheManager,
) : KoinComponent { ) : KoinComponent {
private val logger by injectLogger<AlternativeTracksRepository>() private val logger by injectLogger<AlternativeTracksRepository>()
@ -78,6 +79,7 @@ class AlternativeTracksRepository(
matchedTracksRepository.saveTrackSource(track, basic) matchedTracksRepository.saveTrackSource(track, basic)
streamingUrlRepository.invalidateCachedStreamUrl(trackId) streamingUrlRepository.invalidateCachedStreamUrl(trackId)
streamingUrlRepository.invalidateCachedAlternatives(trackId) streamingUrlRepository.invalidateCachedAlternatives(trackId)
cacheManager.invalidateCacheEntry(trackId)
audioPlayerQueue.reloadCurrent() audioPlayerQueue.reloadCurrent()
logger.d { "Alternative source selection complete for track $trackId" } 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.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.paths.Paths import dev.krtirtho.spotube.core.paths.Paths
import dev.krtirtho.spotube.modules.settings.SettingsRepository
import io.ktor.http.ContentType import io.ktor.http.ContentType
import io.ktor.http.Headers import io.ktor.http.Headers
import io.ktor.http.HeadersBuilder import io.ktor.http.HeadersBuilder
@ -40,12 +41,12 @@ import okio.buffer
import okio.use import okio.use
@Serializable @Serializable
internal data class CacheIndex( data class CacheIndex(
val entries: List<CacheEntry> = emptyList() val entries: List<CacheEntry> = emptyList()
) )
@Serializable @Serializable
internal data class CacheEntry( data class CacheEntry(
val trackId: String, val trackId: String,
val filename: String, val filename: String,
val sizeBytes: Long, val sizeBytes: Long,
@ -53,18 +54,26 @@ internal data class CacheEntry(
val contentType: String = "application/octet-stream", val contentType: String = "application/octet-stream",
) )
internal class CacheManager( class CacheManager(
private val paths: Paths, private val paths: Paths,
private val resolveCacheFolder: () -> String?, private val settingsRepository: SettingsRepository,
private val resolveSizeLimitMB: () -> Long,
private val fileSystem: FileSystem = FileSystem.SYSTEM,
private val cacheMutex: Mutex = Mutex(),
private val json: Json = Json { ignoreUnknownKeys = true },
) { ) {
private val fileSystem: FileSystem = FileSystem.SYSTEM
private val cacheMutex: Mutex = Mutex()
private val json: Json = Json { ignoreUnknownKeys = true }
companion object { companion object {
private val cacheIndexFileName = "cache_index.json".toPath() 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 { fun resolveCacheDir(): Path {
val folder = resolveCacheFolder() val folder = resolveCacheFolder()
return if (!folder.isNullOrBlank()) { return if (!folder.isNullOrBlank()) {
@ -101,6 +110,18 @@ internal class CacheManager(
return if (fileSystem.exists(filePath)) filePath to entry else null 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 { fun resolveCacheFilename(track: MetadataTrack, contentType: String?): String {
val artists = track.artists.joinToString(", ") { it.name }.sanitizeFilenamePart() val artists = track.artists.joinToString(", ") { it.name }.sanitizeFilenamePart()
val title = track.title.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.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.core.di.injectLogger
import dev.krtirtho.spotube.core.paths.Paths
import dev.krtirtho.spotube.modules.settings.SettingsViewModel import dev.krtirtho.spotube.modules.settings.SettingsViewModel
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.http.HttpMethod import io.ktor.http.HttpMethod
@ -52,10 +51,10 @@ import kotlinx.coroutines.sync.withLock
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
class LocalServer( class LocalServer(
private val paths: Paths,
settingsViewModel: SettingsViewModel, settingsViewModel: SettingsViewModel,
private val streamingUrlRepository: StreamingUrlRepository, private val streamingUrlRepository: StreamingUrlRepository,
private val audioPlayerQueue: AudioPlayerQueue, private val audioPlayerQueue: AudioPlayerQueue,
private val cacheManager: CacheManager,
) : KoinComponent { ) : KoinComponent {
val logger by injectLogger<LocalServer>() val logger by injectLogger<LocalServer>()
@ -76,14 +75,6 @@ class LocalServer(
}.stateIn(scope, SharingStarted.WhileSubscribed(5_000), null) }.stateIn(scope, SharingStarted.WhileSubscribed(5_000), null)
private val cachedCacheEnabled = MutableStateFlow(false) 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 { private val streamProxy by lazy {
StreamProxy( StreamProxy(
@ -94,7 +85,6 @@ class LocalServer(
isCachingEnabled = { cachedCacheEnabled.value }, isCachingEnabled = { cachedCacheEnabled.value },
activePort = { activePort.value }, activePort = { activePort.value },
scope = scope, scope = scope,
logger = logger,
) )
} }
@ -118,8 +108,6 @@ class LocalServer(
.collect { settings -> .collect { settings ->
if (settings != null) { if (settings != null) {
cachedCacheEnabled.value = settings.enableMusicCaching 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.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.di.injectLogger
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.request.request import io.ktor.client.request.request
import io.ktor.client.statement.HttpResponse import io.ktor.client.statement.HttpResponse
@ -51,6 +52,7 @@ import okio.Path.Companion.toPath
import okio.SYSTEM import okio.SYSTEM
import okio.buffer import okio.buffer
import okio.use import okio.use
import org.koin.core.component.KoinComponent
internal class StreamProxy( internal class StreamProxy(
private val httpClient: HttpClient, private val httpClient: HttpClient,
@ -60,8 +62,9 @@ internal class StreamProxy(
private val isCachingEnabled: () -> Boolean, private val isCachingEnabled: () -> Boolean,
private val activePort: () -> Int?, private val activePort: () -> Int?,
private val scope: CoroutineScope, private val scope: CoroutineScope,
private val logger: co.touchlab.kermit.Logger, ): KoinComponent {
) { val logger by injectLogger<StreamProxy>()
companion object { companion object {
private const val HOST = "127.0.0.1" 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.edit
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import dev.krtirtho.spotube.core.db.Database 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.coroutines.flow.*
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@ -28,14 +32,20 @@ class SettingsRepository(private val database: Database) {
private val SETTINGS_KEY = stringPreferencesKey("user_settings") 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] val json = prefs[SETTINGS_KEY]
if (json != null) { if (json != null) {
Json.decodeFromString<UserSettings>(json as String) Json.decodeFromString<UserSettings>(json as String)
} else { } else {
UserSettings() // Default value UserSettings() // Default value
} }
} }.stateIn(
scope,
started = SharingStarted.Eagerly,
initialValue = UserSettings() // Default value
)
suspend fun updateSettings(newSettings: UserSettings) { suspend fun updateSettings(newSettings: UserSettings) {
database.settingsDataStore.edit { prefs -> database.settingsDataStore.edit { prefs ->

View File

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

View File

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