From 2d4cb02aeb8dc4171f97ee9bf46b7e910a8bfa80 Mon Sep 17 00:00:00 2001 From: pgwiz Date: Tue, 27 Oct 2026 12:42:14 +0300 Subject: [PATCH 01/16] docs: add manual plugin install guide and verify pgwiz plugin Co-Authored-By: pgwiz --- README.md | 12 ++++++++++++ .../spotube/spotube/modules/plugin/PluginScreen.kt | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 2cc7453b..1a9ab6ff 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,18 @@ Download Spotube from the Official Website: https://spotube.cc Grab the latest nightly builds of Spotube [from the Offical Website](https://spotube.cc/downloads/nightly). +### 🧩 Plugins + +Spotube supports metadata and source plugins compiled as `.smplug` files. + +You can install plugins directly in Spotube: +1. Navigate to **Settings** > **Plugins** > **Install a Plugin**. +2. Under **Download from URL**, paste the direct plugin URL, for example: + - **Spotify Plugin (Latest)**: `https://github.com/pgwiz/spotube-plugin-spotify/releases/latest/download/plugin.smplug` + - **Spotify Plugin (v1.0.0)**: `https://github.com/pgwiz/spotube-plugin-spotify/releases/download/1.0.0/plugin.smplug` + - **Legacy Plugin Reference**: `https://github.com/sonic-liberation/spotube-plugin-spotify/releases/download/0.2.2/plugin.smplug` +3. Click **Download**, review permissions, and confirm. + ## 🕳️ Building from source GitHub Workflow Status diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginScreen.kt index 0d52c279..ae37b08e 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginScreen.kt @@ -182,7 +182,7 @@ import spotube.composeapp.generated.resources.settings_plugins_plugin_content_de private val OFFICIAL_PLUGIN_OWNERS = setOf("KRTirtho", "team-spotube") -private val VERIFIED_PLUGIN_OWNERS = setOf() +private val VERIFIED_PLUGIN_OWNERS = setOf("pgwiz", "sonic-liberation") @Composable fun PluginScreen( From a573570de91c4b31e54c9efe0c2054576ad38877 Mon Sep 17 00:00:00 2001 From: pgwiz Date: Tue, 27 Oct 2026 14:09:02 +0300 Subject: [PATCH 02/16] fix(plugins): support plugin forks in discovery search and update legacy URL format in docs Co-Authored-By: pgwiz --- README.md | 2 +- .../org/spotube/spotube/modules/plugin/GitHubPluginRepo.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1a9ab6ff..4960979c 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ You can install plugins directly in Spotube: 2. Under **Download from URL**, paste the direct plugin URL, for example: - **Spotify Plugin (Latest)**: `https://github.com/pgwiz/spotube-plugin-spotify/releases/latest/download/plugin.smplug` - **Spotify Plugin (v1.0.0)**: `https://github.com/pgwiz/spotube-plugin-spotify/releases/download/1.0.0/plugin.smplug` - - **Legacy Plugin Reference**: `https://github.com/sonic-liberation/spotube-plugin-spotify/releases/download/0.2.2/plugin.smplug` + - **Legacy Plugin Reference**: `https://github.com/sonic-liberation/spotube-plugin-spotify/releases/download/0.2.2/plugin.smplug` *(Format: `https://github.com/$LEGACY_REPO/releases/download/$LEGACY_TAG/plugin.smplug`)* 3. Click **Download**, review permissions, and confirm. ## 🕳️ Building from source diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/GitHubPluginRepo.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/GitHubPluginRepo.kt index 86a8f30b..014a1df2 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/GitHubPluginRepo.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/GitHubPluginRepo.kt @@ -72,7 +72,7 @@ class GitHubPluginRepository(private val httpClient: HttpClient) { append("Accept", "application/vnd.github+json") append("X-GitHub-Api-Version", "2022-11-28") } - parameter("q", "topic:spotube-zipline-plugin") + parameter("q", "topic:spotube-zipline-plugin fork:true") parameter("sort", "stars") parameter("order", "desc") parameter("page", page) From e4fe197184c30427b5c195cf5667dad00af051d1 Mon Sep 17 00:00:00 2001 From: pgwiz Date: Wed, 28 Oct 2026 00:34:20 +0300 Subject: [PATCH 03/16] fix(player): resolve GoogleVideo 403, YouTube video ID extraction, matching relaxation, and radio mix fallback Co-Authored-By: pgwiz --- CHANGELOG.md | 9 ++ .../core/newpipe/NewPipeService.androidJvm.kt | 41 +++++++ .../audioplayer/DeviceAudioPlayerQueue.kt | 80 ++++++++++++-- .../spotube/core/newpipe/NewPipeService.kt | 1 + .../spotube/core/server/StreamProxy.kt | 51 +++++++-- .../newpipe_yt/RealNewPipeAudioAPI.kt | 103 +++++++++++++----- .../core/newpipe/NewPipeService.ios.kt | 25 +++++ gradle/wrapper/gradle-wrapper.properties | 2 +- 8 files changed, 264 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bfa5a20..34e50351 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## [Unreleased] + +### Fixes +- **server/StreamProxy**: Prevent GoogleVideo CDN HTTP 403 Forbidden by injecting `Referer: https://www.youtube.com/` and browser `User-Agent` headers on `googlevideo.com` and `youtube.com` proxy requests, preserving stream range headers (`Range`, `If-Range`) and filtering hop-by-hop headers. +- **newpipe/RealNewPipeAudioAPI**: Fix YouTube video ID extraction when `track.externalUri` contains YouTube URLs (`v=`, `youtu.be`, `/embed/`, `/shorts/`) instead of passing raw Spotify track IDs to video info lookups. +- **newpipe/RealNewPipeAudioAPI**: Improve track matching and scoring for Topic channels, VEVO uploads, and official variants with relaxed duration bins, and fall back to top relevance search result when confidence scoring yields no candidates $\ge 0.3$. +- **newpipe/NewPipeService**: Implement native YouTube radio mix extractor (`RD{videoId}`) across commonMain, androidJvmMain, and iosMain platforms. +- **audioplayer/DeviceAudioPlayerQueue**: Add native YouTube radio mix fallback for endless playback queue continuation when metadata recommendations fail or are unauthenticated. + ## [6.0.0-beta.0](https://github.com/team-spotube/spotube/compare/v5.1.2...v6.0.0-beta.0) (2026-10-26) ### Features diff --git a/composeApp/src/androidJvmMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.androidJvm.kt b/composeApp/src/androidJvmMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.androidJvm.kt index 1500c6f9..4ea33a6e 100644 --- a/composeApp/src/androidJvmMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.androidJvm.kt +++ b/composeApp/src/androidJvmMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.androidJvm.kt @@ -155,6 +155,47 @@ actual class NewPipeService { ) } } + + actual suspend fun getRadioMix(videoId: String): List { + return withContext(Dispatchers.IO) { + val urls = listOf( + "https://www.youtube.com/watch?v=$videoId&list=RD$videoId", + "https://www.youtube.com/playlist?list=RD$videoId" + ) + for (url in urls) { + val items = runCatching { + val extractor = YouTube.getPlaylistExtractor(url) + extractor.fetchPage() + val page = extractor.initialPage + page.items.mapNotNull { item -> + if (item is StreamInfoItem) { + val vId = runCatching { Url(item.url).parameters["v"] }.getOrNull() + ?: item.url.substringAfter("watch?v=", "").substringBefore("&") + .takeIf { it.isNotEmpty() } + ?: item.url.substringAfterLast("/") + VideoSearchResult( + id = vId, + title = item.name, + url = item.url, + uploader = item.uploaderName, + durationMs = secondsToMs(item.duration), + thumbnailUrl = item.thumbnails.firstOrNull()?.url.orEmpty() + ) + } else null + } + }.onFailure { + logger.w(it) { "Failed to extract YouTube radio mix from $url" } + }.getOrNull() + + if (!items.isNullOrEmpty()) { + logger.i { "Extracted ${items.size} tracks from YouTube radio mix for video $videoId" } + return@withContext items + } + } + logger.w { "Unable to fetch radio mix for video $videoId" } + emptyList() + } + } } /** diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt index fb283055..f3e044ff 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt @@ -18,7 +18,12 @@ package org.spotube.spotube.core.audioplayer import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.Thumbnail +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.core.newpipe.NewPipeService +import org.spotube.spotube.core.newpipe.SearchKind import org.spotube.spotube.modules.blacklist.BlacklistRepository import org.spotube.spotube.modules.plugin.PluginProvider import org.spotube.spotube.modules.settings.SettingsProvider @@ -330,19 +335,74 @@ class DeviceAudioPlayerQueue( isFetchingRecommendations = true logger.i { "Endless playback: fetching recommendations with ${seedTrackIds.size} seed tracks" } - val metadataService = pluginProvider.selectedMetadataPlugin.value ?: run { - logger.w { "Endless playback: no metadata plugin available" } - isFetchingRecommendations = false - return + val metadataService = pluginProvider.selectedMetadataPlugin.value + var recommendations: List = emptyList() + + if (metadataService != null) { + recommendations = runCatching { + metadataService.use { + metadataTrackAPI.recommendationsBasedOnTracks(seedTrackIds, limit = 20) + } + }.getOrDefault(emptyList()) + } + + if (recommendations.isEmpty()) { + logger.i { "Endless playback: metadata recommendations unavailable or empty, falling back to native YouTube radio mix" } + val newPipeService = NewPipeService() + for (seedTrack in seedTracks) { + val seed = seedTrack.track + val videoId = if (seed.id.matches(Regex("^[a-zA-Z0-9_-]{11}$"))) { + seed.id + } else { + val extracted = seed.externalUri?.let { uri -> + Regex("""[?&]v=([a-zA-Z0-9_-]{11})""").find(uri)?.groupValues?.get(1) + ?: Regex("""youtu\.be/([a-zA-Z0-9_-]{11})""").find(uri)?.groupValues?.get(1) + } + extracted ?: runCatching { + val artist = seed.artists.firstOrNull()?.name.orEmpty() + newPipeService.searchVideos("${seed.title} $artist".trim(), SearchKind.Songs) + .firstOrNull()?.id + }.getOrNull() + } + + if (!videoId.isNullOrBlank()) { + val radioMix = runCatching { newPipeService.getRadioMix(videoId) }.getOrDefault(emptyList()) + if (radioMix.isNotEmpty()) { + logger.i { "Endless playback: got ${radioMix.size} tracks from YouTube radio mix for video $videoId" } + recommendations = radioMix.map { video -> + MetadataTrack( + id = video.id, + title = video.title, + durationMs = video.durationMs, + trackNumber = null, + discNumber = null, + artists = listOf( + MetadataArtist.Basic( + id = video.uploader, + name = video.uploader, + thumbnails = emptyList(), + externalUri = null + ) + ), + album = null, + thumbnails = if (video.thumbnailUrl.isNotBlank()) { + listOf(Thumbnail(url = video.thumbnailUrl, width = 300, height = 300)) + } else null, + explicit = null, + popularity = null, + isrcCode = null, + externalUri = "https://www.youtube.com/watch?v=${video.id}" + ) + } + break + } + } + } } try { - val recommendations = metadataService.use { - metadataTrackAPI.recommendationsBasedOnTracks(seedTrackIds, limit = 20) - } - if (recommendations.isEmpty()) { - logger.w { "Endless playback: no recommendations returned" } + logger.w { "Endless playback: no recommendations or radio mix tracks found" } isFetchingRecommendations = false return } @@ -369,7 +429,7 @@ class DeviceAudioPlayerQueue( logger.i { "Endless playback: adding ${newEntries.size} recommended tracks to queue" } addAllToQueue(newEntries) } catch (e: Exception) { - logger.e(e) { "Endless playback: failed to fetch recommendations" } + logger.e(e) { "Endless playback: failed to add recommendations to queue" } } finally { isFetchingRecommendations = false } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.kt index d624f10b..09ac823a 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.kt @@ -28,4 +28,5 @@ enum class SearchKind { expect class NewPipeService() { suspend fun searchVideos(query: String, kind: SearchKind = SearchKind.Songs): List suspend fun getVideoInfo(id: String): VideoInfo + suspend fun getRadioMix(videoId: String): List } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamProxy.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamProxy.kt index 900d830a..c8ae43f5 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamProxy.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamProxy.kt @@ -34,6 +34,7 @@ import io.ktor.http.HeadersBuilder import io.ktor.http.HttpHeaders import io.ktor.http.HttpMethod import io.ktor.http.HttpStatusCode +import io.ktor.http.Url import io.ktor.http.content.OutgoingContent import io.ktor.http.decodeURLQueryComponent import io.ktor.http.encodeURLParameter @@ -72,6 +73,9 @@ internal class StreamProxy( companion object { private const val HOST = "127.0.0.1" + private const val YOUTUBE_REFERER = "https://www.youtube.com/" + private const val BROWSER_USER_AGENT = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" } private val fileSystem = FileSystem.SYSTEM @@ -233,8 +237,7 @@ internal class StreamProxy( val response = runCatching { httpClient.request(manifestUrl) { method = HttpMethod.Get - call.forwardRequestHeaderIfPresent(headers, HttpHeaders.Accept) - call.forwardRequestHeaderIfPresent(headers, HttpHeaders.UserAgent) + applyUpstreamHeaders(call, this, manifestUrl, includeRange = false) } }.getOrElse { throwable -> logger.w(throwable) { "Failed to fetch manifest for track $trackId" } @@ -284,9 +287,7 @@ internal class StreamProxy( runCatching { httpClient.prepareRequest(segmentUrl) { method = HttpMethod.Get - call.forwardRequestHeaderIfPresent(headers, HttpHeaders.Range) - call.forwardRequestHeaderIfPresent(headers, HttpHeaders.Accept) - call.forwardRequestHeaderIfPresent(headers, HttpHeaders.UserAgent) + applyUpstreamHeaders(call, this, segmentUrl, includeRange = true) }.execute { response -> respondWithSegment(call, response, trackId) } }.onFailure { throwable -> if (throwable is CancellationException) throw throwable @@ -343,10 +344,7 @@ internal class StreamProxy( val failure = runCatching { httpClient.prepareRequest(currentUrl) { method = requestMethod - call.forwardRequestHeaderIfPresent(headers, HttpHeaders.Range) - call.forwardRequestHeaderIfPresent(headers, HttpHeaders.IfRange) - call.forwardRequestHeaderIfPresent(headers, HttpHeaders.Accept) - call.forwardRequestHeaderIfPresent(headers, HttpHeaders.UserAgent) + applyUpstreamHeaders(call, this, currentUrl, includeRange = true) }.execute { response -> logger.i { "[Req] upstream headers for $trackId after ${upstreamStart.elapsedNow()}: " + @@ -578,6 +576,35 @@ internal class StreamProxy( private fun encodeUrl(url: String): String = url.encodeURLParameter() private fun decodeUrl(encoded: String): String = encoded.decodeURLQueryComponent() + private fun isYouTubeOrGoogleVideo(url: String): Boolean { + val host = runCatching { Url(url).host.lowercase() }.getOrNull() ?: "" + return host.endsWith("googlevideo.com") || + host.endsWith("youtube.com") || + url.contains("googlevideo.com", ignoreCase = true) || + url.contains("youtube.com", ignoreCase = true) + } + + private fun applyUpstreamHeaders( + call: ApplicationCall, + builder: io.ktor.client.request.HttpRequestBuilder, + targetUrl: String, + includeRange: Boolean + ) { + if (includeRange) { + call.forwardRequestHeaderIfPresent(builder.headers, HttpHeaders.Range) + call.forwardRequestHeaderIfPresent(builder.headers, HttpHeaders.IfRange) + } + call.forwardRequestHeaderIfPresent(builder.headers, HttpHeaders.Accept) + + if (isYouTubeOrGoogleVideo(targetUrl)) { + builder.headers[HttpHeaders.Referrer] = YOUTUBE_REFERER + builder.headers[HttpHeaders.UserAgent] = BROWSER_USER_AGENT + } else { + call.forwardRequestHeaderIfPresent(builder.headers, HttpHeaders.UserAgent) + call.forwardRequestHeaderIfPresent(builder.headers, HttpHeaders.Referrer) + } + } + private fun ApplicationCall.forwardRequestHeaderIfPresent( builder: HeadersBuilder, headerName: String @@ -590,7 +617,11 @@ internal class StreamProxy( private fun upstreamHeadersToForward(upstream: HttpResponse): Headers { return HeadersBuilder().apply { upstream.headers.forEach { name, values -> - values.forEach { value -> append(name, value) } + if (!name.equals(HttpHeaders.TransferEncoding, ignoreCase = true) && + !name.equals(HttpHeaders.Connection, ignoreCase = true) + ) { + values.forEach { value -> append(name, value) } + } } }.build() } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/newpipe_yt/RealNewPipeAudioAPI.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/newpipe_yt/RealNewPipeAudioAPI.kt index 46174a37..4dbbb166 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/newpipe_yt/RealNewPipeAudioAPI.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/newpipe_yt/RealNewPipeAudioAPI.kt @@ -55,29 +55,47 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent { var confidence = 0f val videoTitle = normalize(video.title) - val isTopicChannel = video.uploader.trim().endsWith("- Topic", ignoreCase = true) - val uploader = normalize(video.uploader.replace(Regex("(?i)\\s*-\\s*topic$"), "")) + val isTopicChannel = video.uploader.trim().endsWith("- Topic", ignoreCase = true) || + video.uploader.trim().contains("Topic", ignoreCase = true) + val uploaderRaw = video.uploader.replace(Regex("(?i)\\s*-\\s*topic$"), "").trim() + val uploader = normalize(uploaderRaw) val artistNames = track.artists.map { normalize(it.name) }.filter { it.isNotEmpty() } val title = coreTitle(track.title) val videoTokens = videoTitle.split(' ').toSet() val isTitleMatch = title.isNotEmpty() && - (title in videoTitle || title.split(' ').all { it in videoTokens }) + (title in videoTitle || videoTitle in title || title.split(' ').filter { it.length > 2 }.all { it in videoTokens }) val isArtistInTitle = artistNames.any { it in videoTitle } val isAlbumInTitle = track.album?.title?.let { normalize(it) } ?.takeIf { it.isNotEmpty() }?.let { it in videoTitle } ?: false - val isUploaderMatch = uploader.isNotEmpty() && artistNames.any { it in uploader || uploader in it } - // Unknown duration (0) must neither reward nor punish - val durationDiff = if (video.durationMs > 0) kotlin.math.abs(video.durationMs - track.durationMs) else Long.MAX_VALUE + + // Normalize whitespace and punctuation for uploader comparison (e.g. "Taylor Swift" vs "TaylorSwiftVEVO") + val uploaderCompact = uploader.replace(Regex("(?i)vevo$"), "").replace(" ", "") + val artistCompacts = artistNames.map { it.replace(" ", "") } + val isUploaderMatch = uploader.isNotEmpty() && ( + artistNames.any { it in uploader || uploader in it } || + (uploaderCompact.isNotEmpty() && artistCompacts.any { it.isNotEmpty() && (it in uploaderCompact || uploaderCompact in it) }) + ) + val isVevo = video.uploader.contains("VEVO", ignoreCase = true) + val isOfficialTrack = listOf("official audio", "official video", "official music video", "official visualizer", "official lyric video").any { videoTitle.contains(it) } + + val hasValidDurations = video.durationMs > 0 && track.durationMs > 0 + val durationDiff = if (hasValidDurations) kotlin.math.abs(video.durationMs - track.durationMs) else Long.MAX_VALUE if (isTitleMatch) confidence += 0.3f if (isUploaderMatch) confidence += 0.3f + if (isVevo) confidence += 0.15f + if (isOfficialTrack) confidence += 0.1f + if (isTopicChannel) confidence += if (isUploaderMatch) 0.2f else 0.1f + if (durationDiff <= 10_000) confidence += 0.2f - else if (durationDiff <= 30_000) confidence += 0.1f + else if (durationDiff <= 30_000) confidence += 0.15f + else if (durationDiff <= 60_000) confidence += 0.1f + else if (durationDiff <= 120_000) confidence += 0.05f + else if (!hasValidDurations) confidence += 0.1f // Don't penalize when duration is unknown + if (isArtistInTitle) confidence += 0.1f if (isAlbumInTitle) confidence += 0.1f - // Auto-generated "Artist - Topic" uploads are the studio audio, preferred over videos - if (isTopicChannel && isUploaderMatch) confidence += 0.1f confidence = confidence.coerceAtMost(1f) logger.i { @@ -122,12 +140,29 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent { ) ) + private fun extractYouTubeVideoId(urlOrId: String?): String? { + if (urlOrId.isNullOrBlank()) return null + val trimmed = urlOrId.trim() + if (trimmed.matches(youtubeIDRegex)) return trimmed + + val vParamRegex = Regex("""[?&]v=([a-zA-Z0-9_-]{11})(?:[&#]|$)""") + vParamRegex.find(trimmed)?.groupValues?.get(1)?.let { return it } + + val youtuBeRegex = Regex("""youtu\.be/([a-zA-Z0-9_-]{11})(?:[?#]|$)""") + youtuBeRegex.find(trimmed)?.groupValues?.get(1)?.let { return it } + + val pathRegex = Regex("""youtube\.com/(?:embed|v|shorts)/([a-zA-Z0-9_-]{11})(?:[?#]|$)""") + pathRegex.find(trimmed)?.groupValues?.get(1)?.let { return it } + + return null + } + override suspend fun getStreamsByTrack(track: MetadataTrack): List { - val isYouTubeUrl = - track.externalUri != null && (track.externalUri!!.contains("youtube.com") || track.externalUri!!.contains( - "youtu.be" - )) - val isYouTubeID = track.id.matches(youtubeIDRegex) + val resolvedVideoId = if (track.id.matches(youtubeIDRegex)) { + track.id + } else { + extractYouTubeVideoId(track.externalUri) + } logger.i { "Searching streams for track(${track.id}): ${track.title} by ${ @@ -136,9 +171,9 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent { ) { it.name } }" } - if (isYouTubeID || isYouTubeUrl) { - logger.i { "Track has YouTube ID or URL, fetching video info directly" } - return newPipeService.getVideoInfo(track.id).let { videoInfo -> + if (resolvedVideoId != null) { + logger.i { "Track has YouTube ID or URL ($resolvedVideoId), fetching video info directly" } + return newPipeService.getVideoInfo(resolvedVideoId).let { videoInfo -> listOf( AudioSource.Streamed( id = videoInfo.id, @@ -228,17 +263,31 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent { if (matched.isNotEmpty()) return matched.take(5) - // Nothing scored well. Playing YouTube's best text-search guess beats failing playback, - // and ISRC results are skipped because an ISRC text search tends to return unrelated songs. - val fallback = textSearchResults.values.toList() - .sortedByDescending { it.confidence } // stable: ties keep YouTube's relevance order - .take(5) - .ifEmpty { ranked.take(5) } - logger.w { - "[Search] no confident match for '${track.title}'; falling back to best guesses: " + - fallback.joinToString { "${it.id} conf=${it.confidence} '${it.title}'" } + // When confidence scoring yields no candidates >= 0.3, ensure the top relevance search + // result is returned as fallback rather than throwing TrackNotFoundError or failing. + val fallback = if (textSearchResults.isNotEmpty()) { + textSearchResults.values.toList() + .sortedByDescending { it.confidence } // stable: ties keep YouTube's relevance order + .take(5) + } else if (ranked.isNotEmpty()) { + ranked.take(5) + } else { + runCatching { + newPipeService.searchVideos(plainTitle.ifEmpty { track.title }, SearchKind.Videos) + .map { transformVideo(track, it) } + }.getOrDefault(emptyList()).take(5) } - return fallback + + if (fallback.isNotEmpty()) { + logger.w { + "[Search] no confident match for '${track.title}'; falling back to top relevance result: " + + fallback.joinToString { "${it.id} conf=${it.confidence} '${it.title}'" } + } + return fallback + } + + logger.w { "[Search] no search results found at all for '${track.title}'" } + return emptyList() } override suspend fun getStreamsOfAudioSource(source: AudioSource.Basic): List { diff --git a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.ios.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.ios.kt index 95eb42c1..0b84531f 100644 --- a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.ios.kt +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.ios.kt @@ -86,4 +86,29 @@ actual class NewPipeService { ) } } + + actual suspend fun getRadioMix(videoId: String): List { + return withContext(Dispatchers.IO) { + runCatching { + val extractor = YouTube.getPlaylistExtractor("https://www.youtube.com/watch?v=$videoId&list=RD$videoId") + extractor.fetchPage() + val results = extractor.initialPage + results.items.mapNotNull { item -> + if (item is StreamInfoItem) { + VideoSearchResult( + title = item.name, + url = item.url, + uploader = item.uploaderName ?: "Unknown", + durationMs = secondsToMs(item.duration), + thumbnailUrl = item.thumbnails.first().url, + id = Url(item.url).parameters["v"] + ?: item.url.substringAfter("watch?v=", "").substringBefore("&") + ) + } else { + null + } + } + }.getOrDefault(emptyList()) + } + } } \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 5be30bbe..eef496bc 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.0-all.zip -networkTimeout=10000 +networkTimeout=600000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists From f622b4b155c093d2f3f1948013187b808c398805 Mon Sep 17 00:00:00 2001 From: pgwiz Date: Wed, 28 Oct 2026 01:32:02 +0300 Subject: [PATCH 04/16] fix(player): harden StreamProxy Origin header, YouTube URL regex boundaries, and candidate fallback order Co-Authored-By: pgwiz --- CHANGELOG.md | 10 +++++----- .../core/audioplayer/DeviceAudioPlayerQueue.kt | 5 +++-- .../org/spotube/spotube/core/server/StreamProxy.kt | 7 +++++-- .../plugin_apis/newpipe_yt/RealNewPipeAudioAPI.kt | 11 ++++++----- .../spotube/core/newpipe/NewPipeService.ios.kt | 6 ++++-- 5 files changed, 23 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34e50351..266ae3ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,11 +3,11 @@ ## [Unreleased] ### Fixes -- **server/StreamProxy**: Prevent GoogleVideo CDN HTTP 403 Forbidden by injecting `Referer: https://www.youtube.com/` and browser `User-Agent` headers on `googlevideo.com` and `youtube.com` proxy requests, preserving stream range headers (`Range`, `If-Range`) and filtering hop-by-hop headers. -- **newpipe/RealNewPipeAudioAPI**: Fix YouTube video ID extraction when `track.externalUri` contains YouTube URLs (`v=`, `youtu.be`, `/embed/`, `/shorts/`) instead of passing raw Spotify track IDs to video info lookups. -- **newpipe/RealNewPipeAudioAPI**: Improve track matching and scoring for Topic channels, VEVO uploads, and official variants with relaxed duration bins, and fall back to top relevance search result when confidence scoring yields no candidates $\ge 0.3$. -- **newpipe/NewPipeService**: Implement native YouTube radio mix extractor (`RD{videoId}`) across commonMain, androidJvmMain, and iosMain platforms. -- **audioplayer/DeviceAudioPlayerQueue**: Add native YouTube radio mix fallback for endless playback queue continuation when metadata recommendations fail or are unauthenticated. +- **server/StreamProxy**: Prevent GoogleVideo CDN HTTP 403 Forbidden by injecting `Referer: https://www.youtube.com/`, `Origin: https://www.youtube.com`, and browser `User-Agent` headers on `googlevideo.com`, `youtube.com`, and `youtu.be` proxy requests, preserving stream range headers (`Range`, `If-Range`) without header duplication, and filtering hop-by-hop headers. +- **newpipe/RealNewPipeAudioAPI**: Fix YouTube video ID extraction when `track.externalUri` contains YouTube URLs (`v=`, `youtu.be`, `/embed/`, `/shorts/`, with trailing slash/query/fragment support) instead of passing raw Spotify track IDs to video info lookups. +- **newpipe/RealNewPipeAudioAPI**: Improve track matching and scoring for Topic channels, VEVO uploads, and official variants with relaxed duration bins, and guarantee top relevance search result is prioritized at index 0 when confidence scoring yields no candidates $\ge 0.3$. +- **newpipe/NewPipeService**: Implement native YouTube radio mix extractor (`RD{videoId}`) across commonMain, androidJvmMain, and iosMain platforms with safe thumbnail fallbacks. +- **audioplayer/DeviceAudioPlayerQueue**: Add native YouTube radio mix fallback for endless playback queue continuation when metadata recommendations fail or are unauthenticated, supporting complete YouTube URL patterns. ## [6.0.0-beta.0](https://github.com/team-spotube/spotube/compare/v5.1.2...v6.0.0-beta.0) (2026-10-26) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt index f3e044ff..df28649c 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt @@ -355,8 +355,9 @@ class DeviceAudioPlayerQueue( seed.id } else { val extracted = seed.externalUri?.let { uri -> - Regex("""[?&]v=([a-zA-Z0-9_-]{11})""").find(uri)?.groupValues?.get(1) - ?: Regex("""youtu\.be/([a-zA-Z0-9_-]{11})""").find(uri)?.groupValues?.get(1) + Regex("""[?&]v=([a-zA-Z0-9_-]{11})(?:[&#]|$)""").find(uri)?.groupValues?.get(1) + ?: Regex("""youtu\.be/([a-zA-Z0-9_-]{11})(?:[?#/]|$)""").find(uri)?.groupValues?.get(1) + ?: Regex("""youtube\.com/(?:embed|v|shorts)/([a-zA-Z0-9_-]{11})(?:[?#/]|$)""").find(uri)?.groupValues?.get(1) } extracted ?: runCatching { val artist = seed.artists.firstOrNull()?.name.orEmpty() diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamProxy.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamProxy.kt index c8ae43f5..73671788 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamProxy.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamProxy.kt @@ -580,8 +580,10 @@ internal class StreamProxy( val host = runCatching { Url(url).host.lowercase() }.getOrNull() ?: "" return host.endsWith("googlevideo.com") || host.endsWith("youtube.com") || + host.endsWith("youtu.be") || url.contains("googlevideo.com", ignoreCase = true) || - url.contains("youtube.com", ignoreCase = true) + url.contains("youtube.com", ignoreCase = true) || + url.contains("youtu.be", ignoreCase = true) } private fun applyUpstreamHeaders( @@ -598,6 +600,7 @@ internal class StreamProxy( if (isYouTubeOrGoogleVideo(targetUrl)) { builder.headers[HttpHeaders.Referrer] = YOUTUBE_REFERER + builder.headers[HttpHeaders.Origin] = "https://www.youtube.com" builder.headers[HttpHeaders.UserAgent] = BROWSER_USER_AGENT } else { call.forwardRequestHeaderIfPresent(builder.headers, HttpHeaders.UserAgent) @@ -610,7 +613,7 @@ internal class StreamProxy( headerName: String ) { request.headers.getAll(headerName)?.forEach { value -> - builder.append(headerName, value) + builder[headerName] = value } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/newpipe_yt/RealNewPipeAudioAPI.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/newpipe_yt/RealNewPipeAudioAPI.kt index 4dbbb166..f5a8e12a 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/newpipe_yt/RealNewPipeAudioAPI.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/newpipe_yt/RealNewPipeAudioAPI.kt @@ -148,10 +148,10 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent { val vParamRegex = Regex("""[?&]v=([a-zA-Z0-9_-]{11})(?:[&#]|$)""") vParamRegex.find(trimmed)?.groupValues?.get(1)?.let { return it } - val youtuBeRegex = Regex("""youtu\.be/([a-zA-Z0-9_-]{11})(?:[?#]|$)""") + val youtuBeRegex = Regex("""youtu\.be/([a-zA-Z0-9_-]{11})(?:[?#/]|$)""") youtuBeRegex.find(trimmed)?.groupValues?.get(1)?.let { return it } - val pathRegex = Regex("""youtube\.com/(?:embed|v|shorts)/([a-zA-Z0-9_-]{11})(?:[?#]|$)""") + val pathRegex = Regex("""youtube\.com/(?:embed|v|shorts)/([a-zA-Z0-9_-]{11})(?:[?#/]|$)""") pathRegex.find(trimmed)?.groupValues?.get(1)?.let { return it } return null @@ -266,9 +266,10 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent { // When confidence scoring yields no candidates >= 0.3, ensure the top relevance search // result is returned as fallback rather than throwing TrackNotFoundError or failing. val fallback = if (textSearchResults.isNotEmpty()) { - textSearchResults.values.toList() - .sortedByDescending { it.confidence } // stable: ties keep YouTube's relevance order - .take(5) + val list = textSearchResults.values.toList() + val topRelevance = list.first() + val rest = list.drop(1).sortedByDescending { it.confidence } + (listOf(topRelevance) + rest).take(5) } else if (ranked.isNotEmpty()) { ranked.take(5) } else { diff --git a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.ios.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.ios.kt index 0b84531f..63491598 100644 --- a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.ios.kt +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.ios.kt @@ -100,9 +100,11 @@ actual class NewPipeService { url = item.url, uploader = item.uploaderName ?: "Unknown", durationMs = secondsToMs(item.duration), - thumbnailUrl = item.thumbnails.first().url, - id = Url(item.url).parameters["v"] + thumbnailUrl = item.thumbnails.firstOrNull()?.url.orEmpty(), + id = runCatching { Url(item.url).parameters["v"] }.getOrNull() ?: item.url.substringAfter("watch?v=", "").substringBefore("&") + .takeIf { it.isNotEmpty() } + ?: item.url.substringAfterLast("/") ) } else { null From 3ce36d624b71fc5b8c4464d4d1fe50139c575f1d Mon Sep 17 00:00:00 2001 From: pgwiz Date: Wed, 28 Oct 2026 04:12:49 +0300 Subject: [PATCH 05/16] ci: add standalone build-android-apk workflow with automatic keystore generation Co-Authored-By: pgwiz --- .github/workflows/build-android-apk.yml | 115 ++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 .github/workflows/build-android-apk.yml diff --git a/.github/workflows/build-android-apk.yml b/.github/workflows/build-android-apk.yml new file mode 100644 index 00000000..8f1cd348 --- /dev/null +++ b/.github/workflows/build-android-apk.yml @@ -0,0 +1,115 @@ +name: Build Android APK + +on: + workflow_dispatch: + inputs: + tag: + description: 'Release tag (e.g. v6.0.0-custom or nightly)' + required: false + default: 'nightly' + +permissions: + contents: write + +jobs: + build-apk: + name: Build Spotube Android APK + runs-on: ubuntu-latest + timeout-minutes: 90 + steps: + - name: Checkout spotube + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Checkout ComposeNativeWebview + uses: actions/checkout@v4 + with: + repository: KRTirtho/ComposeNativeWebview + ref: cbd36f88927298a28a2d64c6acf7ec3b2def6f9d + path: compose-webview + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + + - name: Set up Android SDK + uses: android-actions/setup-android@v4 + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Publish compose-webview to mavenLocal + working-directory: compose-webview + run: | + chmod +x gradlew + ./gradlew publishToMavenLocal + + - name: Configure Android signing + working-directory: composeApp + env: + KEYSTORE: ${{ secrets.KEYSTORE }} + KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} + KEYSTORE_KEY_ALIAS: ${{ secrets.KEYSTORE_KEY_ALIAS }} + KEYSTORE_KEY_PASSWORD: ${{ secrets.KEYSTORE_KEY_PASSWORD }} + run: | + chmod +x ../gradlew + if [ -n "$KEYSTORE" ]; then + echo "$KEYSTORE" | base64 --decode > "${{ github.workspace }}/composeApp/upload-keystore.jks" + cat >> local.properties <> local.properties < Date: Wed, 28 Oct 2026 04:27:21 +0300 Subject: [PATCH 06/16] ci: fix webkit2gtk buildNativeLinux failure and heredoc syntax in build-android-apk Co-Authored-By: pgwiz --- .github/workflows/build-android-apk.yml | 29 +++++++++++++++---------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/.github/workflows/build-android-apk.yml b/.github/workflows/build-android-apk.yml index 8f1cd348..301edff0 100644 --- a/.github/workflows/build-android-apk.yml +++ b/.github/workflows/build-android-apk.yml @@ -44,11 +44,18 @@ jobs: - name: Set up Gradle uses: gradle/actions/setup-gradle@v4 + - name: Install Linux build dependencies + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev + - name: Publish compose-webview to mavenLocal working-directory: compose-webview run: | chmod +x gradlew - ./gradlew publishToMavenLocal + mkdir -p src/jvmMain/resources/nucleus/native/linux-x64 + touch src/jvmMain/resources/nucleus/native/linux-x64/libcompose_webview_linux.so + ./gradlew publishToMavenLocal -x :webview-compose:buildNativeLinux -x :webview-compose:verifyPublicationCoordinates --no-daemon - name: Configure Android signing working-directory: composeApp @@ -62,20 +69,20 @@ jobs: if [ -n "$KEYSTORE" ]; then echo "$KEYSTORE" | base64 --decode > "${{ github.workspace }}/composeApp/upload-keystore.jks" cat >> local.properties <> local.properties < Date: Wed, 28 Oct 2026 04:32:15 +0300 Subject: [PATCH 07/16] ci: fix yaml syntax indentation for local.properties signing config Co-Authored-By: pgwiz --- .github/workflows/build-android-apk.yml | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/.github/workflows/build-android-apk.yml b/.github/workflows/build-android-apk.yml index 301edff0..f91d8fa6 100644 --- a/.github/workflows/build-android-apk.yml +++ b/.github/workflows/build-android-apk.yml @@ -68,21 +68,17 @@ jobs: chmod +x ../gradlew if [ -n "$KEYSTORE" ]; then echo "$KEYSTORE" | base64 --decode > "${{ github.workspace }}/composeApp/upload-keystore.jks" - cat >> local.properties <> local.properties + echo "release.signing.storePassword=${KEYSTORE_PASSWORD}" >> local.properties + echo "release.signing.keyAlias=${KEYSTORE_KEY_ALIAS}" >> local.properties + echo "release.signing.keyPassword=${KEYSTORE_KEY_PASSWORD}" >> local.properties else echo "No custom keystore secret found, generating build keystore..." keytool -genkey -v -keystore "${{ github.workspace }}/composeApp/upload-keystore.jks" -alias spotube -keyalg RSA -keysize 2048 -validity 10000 -storepass spotubepass -keypass spotubepass -dname "CN=Spotube, OU=Mobile, O=Spotube, C=US" - cat >> local.properties <> local.properties + echo "release.signing.storePassword=spotubepass" >> local.properties + echo "release.signing.keyAlias=spotube" >> local.properties + echo "release.signing.keyPassword=spotubepass" >> local.properties fi - name: Assemble Android Release APK From 2a2ca8c5a69cdd94c280c6eadc8ebbcbae4123e4 Mon Sep 17 00:00:00 2001 From: pgwiz Date: Wed, 28 Oct 2026 07:36:57 +0300 Subject: [PATCH 08/16] feat: port upstream PRs #3132, #3170, #3150, #3092, #3134, #3147 Co-Authored-By: pgwiz --- CHANGELOG.md | 10 + .../audioplayer/DeviceAudioPlayerQueue.kt | 68 ++++++- .../core/server/MatchTracksRepository.kt | 16 ++ .../spotube/core/server/StreamProxy.kt | 34 +++- .../core/server/StreamingUrlRepository.kt | 1 + .../modules/downloads/DownloadManager.kt | 77 +++++++- .../spotube/modules/plugin/PluginManager.kt | 15 +- .../StreamingUrlRepositoryBehaviorTest.kt | 17 ++ .../server/StreamingUrlRepositoryFakes.kt | 9 + .../core/audioplayer/AudioPlayer.ios.kt | 33 +++- .../core/audioplayer/IosMediaSession.kt | 184 ++++++++++++++++++ gradle/libs.versions.toml | 2 +- iosApp/iosApp/Info.plist | 4 + 13 files changed, 452 insertions(+), 18 deletions(-) create mode 100644 composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/IosMediaSession.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 266ae3ba..6c261e85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,17 @@ ## [Unreleased] +### Features +- **audioplayer/ios**: Implement `IosMediaSession` with `AVAudioSession`, `MPNowPlayingInfoCenter`, and `MPRemoteCommandCenter` for lock-screen / Control Center media controls and background audio playback (PR #3170). +- **audioplayer/ios**: Enable direct streaming resolution for iOS playback in `DeviceAudioPlayerQueue.toMediaItem` (PR #3170). +- **audioplayer/DeviceAudioPlayerQueue**: Implement complete 4-tier endless playback recommendation cascade: metadata recommendations, native YouTube radio mix, album tracks, and artist top tracks (PR #3092, PR #3134). +- **plugin/PluginManager**: Auto-select vacant ability slots on plugin installation and recover fallback providers on plugin removal (PR #3147). + ### Fixes +- **build/android**: Switch `desugar_jdk_libs` to `com.android.tools:desugar_jdk_libs_nio` to resolve Android 9-12 `NoSuchMethodError: java.net.URLEncoder.encode` (PR #3132). +- **server/StreamProxy**: Harden YouTube/GoogleVideo proxy streaming against HTTP 403 Forbidden by inspecting client parameter `c` (`ANDROID`, `IOS`, `VISIONOS`), injecting matching mobile User-Agents, and stripping `Origin` and `Referer` on mobile streams (PR #3150). +- **downloads/DownloadManager**: Prevent HTTP 403 Forbidden on YouTube audio downloads by injecting matching client User-Agents and using `Range: bytes=0-0` byte-range probes instead of bare `HEAD` requests (PR #3150). +- **server/StreamingUrlRepository**: Evict stale cached track sources from `MatchedTracksRepository` when cached source resolution fails before falling back to fresh track lookups (PR #3092, PR #3134). - **server/StreamProxy**: Prevent GoogleVideo CDN HTTP 403 Forbidden by injecting `Referer: https://www.youtube.com/`, `Origin: https://www.youtube.com`, and browser `User-Agent` headers on `googlevideo.com`, `youtube.com`, and `youtu.be` proxy requests, preserving stream range headers (`Range`, `If-Range`) without header duplication, and filtering hop-by-hop headers. - **newpipe/RealNewPipeAudioAPI**: Fix YouTube video ID extraction when `track.externalUri` contains YouTube URLs (`v=`, `youtu.be`, `/embed/`, `/shorts/`, with trailing slash/query/fragment support) instead of passing raw Spotify track IDs to video info lookups. - **newpipe/RealNewPipeAudioAPI**: Improve track matching and scoring for Topic channels, VEVO uploads, and official variants with relaxed duration bins, and guarantee top relevance search result is prioritized at index 0 when confidence scoring yields no candidates $\ge 0.3$. diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt index df28649c..268b5aab 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt @@ -42,6 +42,10 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import org.spotube.spotube.PlatformType +import org.spotube.spotube.core.server.StreamingUrlRepository +import org.spotube.spotube.getPlatform import kotlin.random.Random import kotlin.time.Duration.Companion.milliseconds @@ -53,6 +57,7 @@ class DeviceAudioPlayerQueue( private val blacklistRepository: BlacklistRepository) : AudioPlayerQueue, KoinComponent { private val logger by injectLogger() + private val streamingUrlRepository by inject() private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) @@ -401,9 +406,48 @@ class DeviceAudioPlayerQueue( } } + if (recommendations.isEmpty() && metadataService != null) { + val albumId = seedTracks.mapNotNull { it.track.album?.id }.firstOrNull { it.isNotBlank() } + if (!albumId.isNullOrBlank()) { + logger.i { "Endless playback: radio mix unavailable or empty, falling back to album tracks for album $albumId" } + val albumTracks = runCatching { + metadataService.use { + metadataAlbumAPI.getAlbumTracks(albumId).items + } + }.getOrDefault(emptyList()) + + val existingTrackIds = queue.filterIsInstance().map { it.track.id }.toSet() + val newAlbumTracks = albumTracks.filter { it.id !in existingTrackIds } + if (newAlbumTracks.isNotEmpty()) { + logger.i { "Endless playback: got ${newAlbumTracks.size} remaining album tracks for album $albumId" } + recommendations = newAlbumTracks + } + } + } + + if (recommendations.isEmpty() && metadataService != null) { + logger.i { "Endless playback: album tracks unavailable or empty, falling back to artist top tracks" } + val existingTrackIds = queue.filterIsInstance().map { it.track.id }.toSet() + val artistIds = seedTracks.flatMap { it.track.artists }.map { it.id }.filter { it.isNotBlank() }.distinct() + for (artistId in artistIds) { + val topTracks = runCatching { + metadataService.use { + metadataArtistAPI.getArtistTop10Tracks(artistId) + } + }.getOrDefault(emptyList()) + + val newTracks = topTracks.filter { it.id !in existingTrackIds } + if (newTracks.isNotEmpty()) { + logger.i { "Endless playback: got ${newTracks.size} top tracks for artist $artistId" } + recommendations = newTracks + break + } + } + } + try { if (recommendations.isEmpty()) { - logger.w { "Endless playback: no recommendations or radio mix tracks found" } + logger.w { "Endless playback: no recommendations, radio mix, album tracks, or artist top tracks found" } isFetchingRecommendations = false return } @@ -517,7 +561,25 @@ class DeviceAudioPlayerQueue( private suspend fun QueueEntry.toMediaItem(): MediaItem? { return when (this) { is QueueEntry.StreamingTrack -> { - val resolvedUrl = url.ifBlank { buildStreamingUrl(track.id, protocol) } + val isIos = getPlatform().type == PlatformType.IOS + val requiresIosResolution = isIos && ( + url.isBlank() || + url.startsWith("http://127.0.0.1") || + url.startsWith("https://127.0.0.1") || + url.startsWith("http://[::1]") || + url.startsWith("https://[::1]") || + url.startsWith("http://localhost") || + url.startsWith("https://localhost")) + val resolvedStream = if (requiresIosResolution) { + streamingUrlRepository.resolveStreamInfo(track) + } else { + null + } + val resolvedUrl = if (requiresIosResolution) { + resolvedStream?.url ?: buildStreamingUrl(track.id, protocol) + } else { + url.ifBlank { buildStreamingUrl(track.id, protocol) } + } if (resolvedUrl.isBlank()) return null MediaItem( title = track.title, @@ -526,7 +588,7 @@ class DeviceAudioPlayerQueue( duration = track.durationMs.milliseconds, coverURL = artworkUrl.orEmpty(), url = resolvedUrl, - protocol = protocol) + protocol = resolvedStream?.protocol ?: protocol) } is QueueEntry.LocalTrack -> { diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/MatchTracksRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/MatchTracksRepository.kt index a93bb012..f4baff0f 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/MatchTracksRepository.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/MatchTracksRepository.kt @@ -43,6 +43,7 @@ data class PluginTaggedAudioSource( interface TrackSourceRepository { suspend fun getTrackSource(track: MetadataTrack, pluginId: String): AudioSource.Basic? suspend fun saveTrackSource(track: MetadataTrack, source: AudioSource.Basic, pluginId: String) + suspend fun deleteTrackSource(track: MetadataTrack, pluginId: String) } class MatchedTracksRepository(private val database: Database) : TrackSourceRepository { @@ -72,4 +73,19 @@ class MatchedTracksRepository(private val database: Database) : TrackSourceRepos Json.encodeToString(PluginTaggedAudioSource(pluginId, source)) } } + + override suspend fun deleteTrackSource(track: MetadataTrack, pluginId: String) { + database.matchedTracksDataStore.edit { prefs -> + val key = stringPreferencesKey(track.id) + val json = prefs[key] ?: return@edit + val tagged = try { + Json.decodeFromString(json) + } catch (_: Exception) { + null + } + if (tagged == null || tagged.pluginId == pluginId) { + prefs.remove(key) + } + } + } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamProxy.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamProxy.kt index 73671788..b510fcbc 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamProxy.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamProxy.kt @@ -76,6 +76,12 @@ internal class StreamProxy( private const val YOUTUBE_REFERER = "https://www.youtube.com/" private const val BROWSER_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" + private const val ANDROID_USER_AGENT = + "com.google.android.youtube/20.10.38 (Linux; U; Android 11) gzip" + private const val IOS_USER_AGENT = + "com.google.ios.youtube/20.10.4 (iPhone16,2; U; CPU iOS 18_3_2 like Mac OS X;)" + private const val VISIONOS_USER_AGENT = + "com.google.visionos.youtube/1.04(RealityDevice17,1; U; CPU visionOS 26_6_0 like Mac OS X; US)" } private val fileSystem = FileSystem.SYSTEM @@ -599,9 +605,31 @@ internal class StreamProxy( call.forwardRequestHeaderIfPresent(builder.headers, HttpHeaders.Accept) if (isYouTubeOrGoogleVideo(targetUrl)) { - builder.headers[HttpHeaders.Referrer] = YOUTUBE_REFERER - builder.headers[HttpHeaders.Origin] = "https://www.youtube.com" - builder.headers[HttpHeaders.UserAgent] = BROWSER_USER_AGENT + val clientParam = runCatching { Url(targetUrl).parameters["c"]?.uppercase() }.getOrNull() + ?: Regex("""[?&]c=([^&#]+)""").find(targetUrl)?.groupValues?.get(1)?.uppercase() + + when (clientParam) { + "ANDROID" -> { + builder.headers[HttpHeaders.UserAgent] = ANDROID_USER_AGENT + builder.headers.remove(HttpHeaders.Referrer) + builder.headers.remove(HttpHeaders.Origin) + } + "IOS" -> { + builder.headers[HttpHeaders.UserAgent] = IOS_USER_AGENT + builder.headers.remove(HttpHeaders.Referrer) + builder.headers.remove(HttpHeaders.Origin) + } + "VISIONOS" -> { + builder.headers[HttpHeaders.UserAgent] = VISIONOS_USER_AGENT + builder.headers.remove(HttpHeaders.Referrer) + builder.headers.remove(HttpHeaders.Origin) + } + else -> { + builder.headers[HttpHeaders.Referrer] = YOUTUBE_REFERER + builder.headers[HttpHeaders.Origin] = "https://www.youtube.com" + builder.headers[HttpHeaders.UserAgent] = BROWSER_USER_AGENT + } + } } else { call.forwardRequestHeaderIfPresent(builder.headers, HttpHeaders.UserAgent) call.forwardRequestHeaderIfPresent(builder.headers, HttpHeaders.Referrer) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamingUrlRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamingUrlRepository.kt index 19692bf6..5b327533 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamingUrlRepository.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamingUrlRepository.kt @@ -186,6 +186,7 @@ class StreamingUrlRepository( } logger.d { "Cached source did not provide a stream for track $trackId; falling back to track lookup" } + matchedTracksRepository.deleteTrackSource(track, audioPlugin.pluginId) } logger.d { "Attempting stream resolution for track $trackId using track lookup" } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadManager.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadManager.kt index 2cbd6775..a5f71c2f 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadManager.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadManager.kt @@ -34,8 +34,10 @@ import io.ktor.client.request.headers import io.ktor.client.statement.bodyAsChannel import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode +import io.ktor.http.Url import io.ktor.http.contentLength import io.ktor.http.isSuccess +import io.ktor.utils.io.discard import io.ktor.utils.io.readRemaining import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope @@ -95,6 +97,15 @@ class DownloadManager( const val MAX_CONCURRENT_DOWNLOADS = 4 private const val SEGMENT_COUNT = 4 private const val CHUNK_SIZE = 256 * 1024 + private const val YOUTUBE_REFERER = "https://www.youtube.com/" + private const val BROWSER_USER_AGENT = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" + private const val ANDROID_USER_AGENT = + "com.google.android.youtube/20.10.38 (Linux; U; Android 11) gzip" + private const val IOS_USER_AGENT = + "com.google.ios.youtube/20.10.4 (iPhone16,2; U; CPU iOS 18_3_2 like Mac OS X;)" + private const val VISIONOS_USER_AGENT = + "com.google.visionos.youtube/1.04(RealityDevice17,1; U; CPU visionOS 26_6_0 like Mac OS X; US)" } private val logger = Logger.withTag("DownloadManager") @@ -362,6 +373,7 @@ class DownloadManager( headers { append(HttpHeaders.Range, "bytes=${segment.start}-${segment.end}") } + applyClientHeaders(url) } logger.d { "[Download ${item.id}] segment ${segment.index} bytes=${segment.start}-${segment.end} -> " + @@ -398,7 +410,9 @@ class DownloadManager( downloadDir: Path) { val tempFile = tempDir / "${item.id}.tmp" - val response = httpClient.get(url) + val response = httpClient.get(url) { + applyClientHeaders(url) + } val totalSize = response.contentLength() ?: 0L logger.i { "[Download ${item.id}] GET -> HTTP ${response.status.value} " + @@ -442,21 +456,30 @@ class DownloadManager( private suspend fun probeUrl(id: String, url: String): Pair { return try { - val response = httpClient.head(url) - val contentLength = response.contentLength() ?: 0L + val response = httpClient.get(url) { + headers { + append(HttpHeaders.Range, "bytes=0-0") + } + applyClientHeaders(url) + } + val contentRange = response.headers[HttpHeaders.ContentRange] + val totalFromContentRange = contentRange?.substringAfterLast('/')?.trim()?.toLongOrNull() + val contentLength = totalFromContentRange ?: response.contentLength() ?: 0L val acceptRanges = response.headers[HttpHeaders.AcceptRanges] - val supportsRanges = acceptRanges?.equals("bytes", ignoreCase = true) == true + val supportsRanges = response.status == HttpStatusCode.PartialContent || + acceptRanges?.equals("bytes", ignoreCase = true) == true val contentType = response.headers[HttpHeaders.ContentType] logger.i { - "[Download $id] HEAD -> HTTP ${response.status.value} type=$contentType " + - "length=$contentLength acceptRanges=$acceptRanges" + "[Download $id] PROBE (bytes=0-0) -> HTTP ${response.status.value} type=$contentType " + + "length=$contentLength contentRange=$contentRange acceptRanges=$acceptRanges" } + runCatching { response.bodyAsChannel().discard() } rejectVideo(id, contentType) Pair(contentLength, supportsRanges) } catch (e: DownloadException) { throw e } catch (e: Exception) { - logger.w(e) { "[Download $id] HEAD request failed, falling back to single connection" } + logger.w(e) { "[Download $id] PROBE request failed, falling back to single connection" } Pair(0L, false) } } @@ -472,6 +495,46 @@ class DownloadManager( } } + private fun isYouTubeOrGoogleVideo(url: String): Boolean { + val host = runCatching { Url(url).host.lowercase() }.getOrNull() ?: "" + return host.endsWith("googlevideo.com") || + host.endsWith("youtube.com") || + host.endsWith("youtu.be") || + url.contains("googlevideo.com", ignoreCase = true) || + url.contains("youtube.com", ignoreCase = true) || + url.contains("youtu.be", ignoreCase = true) + } + + private fun io.ktor.client.request.HttpRequestBuilder.applyClientHeaders(targetUrl: String) { + if (isYouTubeOrGoogleVideo(targetUrl)) { + val clientParam = runCatching { Url(targetUrl).parameters["c"]?.uppercase() }.getOrNull() + ?: Regex("""[?&]c=([^&#]+)""").find(targetUrl)?.groupValues?.get(1)?.uppercase() + + when (clientParam) { + "ANDROID" -> { + headers[HttpHeaders.UserAgent] = ANDROID_USER_AGENT + headers.remove(HttpHeaders.Referrer) + headers.remove(HttpHeaders.Origin) + } + "IOS" -> { + headers[HttpHeaders.UserAgent] = IOS_USER_AGENT + headers.remove(HttpHeaders.Referrer) + headers.remove(HttpHeaders.Origin) + } + "VISIONOS" -> { + headers[HttpHeaders.UserAgent] = VISIONOS_USER_AGENT + headers.remove(HttpHeaders.Referrer) + headers.remove(HttpHeaders.Origin) + } + else -> { + headers[HttpHeaders.Referrer] = YOUTUBE_REFERER + headers[HttpHeaders.Origin] = "https://www.youtube.com" + headers[HttpHeaders.UserAgent] = BROWSER_USER_AGENT + } + } + } + } + private fun logResolvedStream(item: DownloadItem, info: StreamInfo) { val settings = settingsRepository.userSettings.value val wanted = settings.downloadMusicFormat diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginManager.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginManager.kt index 13d1f380..49b9b4ab 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginManager.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginManager.kt @@ -579,7 +579,12 @@ class PluginManager( val updatedSelectedPlugins = currentState.selectedPlugins.mapValues { (_, selectedPlugin) -> if (selectedPlugin.id == plugin.id) plugin else selectedPlugin + }.toMutableMap() + for (ability in plugin.abilities) { + if (updatedSelectedPlugins[ability] == null) { + updatedSelectedPlugins[ability] = plugin } + } val newState = PluginManagerStates.Data( plugins = updatedPlugins, selectedPlugins = updatedSelectedPlugins, @@ -599,7 +604,15 @@ class PluginManager( if (currentState is PluginManagerStates.Data) { val updatedPlugins = currentState.plugins.filterNot { it.id == plugin.id } val updatedSelectedPlugins = - currentState.selectedPlugins.filterValues { it.id != plugin.id } + currentState.selectedPlugins.filterValues { it.id != plugin.id }.toMutableMap() + for (ability in plugin.abilities) { + if (updatedSelectedPlugins[ability] == null) { + val fallback = updatedPlugins.firstOrNull { ability in it.abilities } + if (fallback != null) { + updatedSelectedPlugins[ability] = fallback + } + } + } val newState = PluginManagerStates.Data( plugins = updatedPlugins, selectedPlugins = updatedSelectedPlugins, diff --git a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt index f297b17a..0e104019 100644 --- a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt @@ -447,6 +447,23 @@ class StreamingUrlRepositoryBehaviorTest { assertEquals("https://example.com/fresh.opus", info?.url) assertEquals(1, plugin.audioAPI.getStreamsOfAudioSourceCallCount) assertEquals(1, plugin.audioAPI.getStreamsByTrackCallCount) + assertEquals(1, h.matchedTracksRepository.deleteCallCount) + } + + @Test + fun `deletes cached track source when cached source fails to provide a stream`() = runTest { + val h = Harness() + val t = track("t1") + val plugin = h.selectPlugin("pluginA") + h.matchedTracksRepository.seed(t, basicSource("stale-1"), pluginId = "pluginA") + plugin.audioAPI.onGetStreamsOfAudioSource = { emptyList() } + plugin.audioAPI.onGetStreamsByTrack = { emptyList() } + + val info = h.repository.resolveStreamInfo(t) + + assertNull(info) + assertEquals(1, h.matchedTracksRepository.deleteCallCount) + assertNull(h.matchedTracksRepository.rawEntryFor(t.id)) } @Test diff --git a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryFakes.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryFakes.kt index d8f1af5f..79570f4d 100644 --- a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryFakes.kt +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryFakes.kt @@ -68,6 +68,7 @@ class FakeTrackSourceRepository : TrackSourceRepository { private val store = mutableMapOf() var getCallCount = 0 var saveCallCount = 0 + var deleteCallCount = 0 override suspend fun getTrackSource(track: MetadataTrack, pluginId: String): AudioSource.Basic? { getCallCount++ @@ -81,6 +82,14 @@ class FakeTrackSourceRepository : TrackSourceRepository { store[track.id] = PluginTaggedAudioSource(pluginId, source) } + override suspend fun deleteTrackSource(track: MetadataTrack, pluginId: String) { + deleteCallCount++ + val tagged = store[track.id] ?: return + if (tagged.pluginId == pluginId) { + store.remove(track.id) + } + } + fun rawEntryFor(trackId: String): PluginTaggedAudioSource? = store[trackId] fun seed(track: MetadataTrack, source: AudioSource.Basic, pluginId: String) { diff --git a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.ios.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.ios.kt index 3c99747b..2b5d4c4c 100644 --- a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.ios.kt +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.ios.kt @@ -62,6 +62,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface private val avPlayer: AVPlayer = AVPlayer() private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val mediaSession = IosMediaSession() private var disposed = false @@ -97,6 +98,13 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface private var lastTimeControlStatus: AVPlayerTimeControlStatus? = null init { + mediaSession.configure( + onPlay = { scope.launch { play() } }, + onPause = { scope.launch { pause() } }, + onSeek = { seconds -> scope.launch { seekTo(seconds.seconds) } }, + onNext = { scope.launch { skipToNext() } }, + onPrevious = { scope.launch { skipToPrevious() } }) + avPlayer.addPeriodicTimeObserverForInterval( interval = CMTimeMake(1, 4), queue = null @@ -115,6 +123,11 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface // val range = loadedRanges.objectAtIndex(0u) // } } + mediaSession.update( + item = _currentMediaItem.value, + positionSeconds = seconds, + durationSeconds = _duration.value.inWholeMilliseconds / 1000.0, + isPlaying = avPlayer.timeControlStatus == AVPlayerTimeControlStatusPlaying) } } @@ -159,6 +172,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface } actual override suspend fun play() { + mediaSession.activate() avPlayer.play() } @@ -170,6 +184,12 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface avPlayer.pause() avPlayer.seekToTime(CMTimeMake(0, 1)) _playerState.tryEmit(PlayerState.IDLE) + mediaSession.deactivate() + mediaSession.update( + item = _currentMediaItem.value, + positionSeconds = 0.0, + durationSeconds = _duration.value.inWholeMilliseconds / 1000.0, + isPlaying = false) } actual override suspend fun seekTo(position: Duration) { @@ -220,9 +240,14 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface avPlayer.replaceCurrentItemWithPlayerItem(avItem) _currentMediaItem.tryEmit(item) + mediaSession.update( + item = item, + positionSeconds = 0.0, + durationSeconds = item.duration.inWholeMilliseconds / 1000.0, + isPlaying = false) if (autoPlay) { - avPlayer.play() + play() } } @@ -277,7 +302,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface if (avItem != null) { avPlayer.replaceCurrentItemWithPlayerItem(avItem) _currentMediaItem.tryEmit(nextItem) - avPlayer.play() + play() } } } @@ -293,7 +318,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface if (avItem != null) { avPlayer.replaceCurrentItemWithPlayerItem(avItem) _currentMediaItem.tryEmit(prevItem) - avPlayer.play() + play() } } } @@ -327,6 +352,8 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface disposed = true avPlayer.pause() avPlayer.replaceCurrentItemWithPlayerItem(null) + mediaSession.deactivate() + mediaSession.clear() currentPlaylist.clear() urlIndexMap.clear() diff --git a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/IosMediaSession.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/IosMediaSession.kt new file mode 100644 index 00000000..18b021fb --- /dev/null +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/IosMediaSession.kt @@ -0,0 +1,184 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +@file:OptIn(ExperimentalForeignApi::class) + +package org.spotube.spotube.core.audioplayer + +import kotlinx.cinterop.ExperimentalForeignApi +import platform.AVFAudio.AVAudioSession +import platform.AVFAudio.AVAudioSessionCategoryPlayback +import platform.AVFAudio.AVAudioSessionModeDefault +import platform.Foundation.NSDictionary +import platform.Foundation.NSMutableDictionary +import platform.Foundation.NSNotificationCenter +import platform.Foundation.NSOperationQueue +import platform.MediaPlayer.MPChangePlaybackPositionCommandEvent +import platform.MediaPlayer.MPMediaItemPropertyAlbumTitle +import platform.MediaPlayer.MPMediaItemPropertyArtist +import platform.MediaPlayer.MPMediaItemPropertyPlaybackDuration +import platform.MediaPlayer.MPMediaItemPropertyTitle +import platform.MediaPlayer.MPNowPlayingInfoCenter +import platform.MediaPlayer.MPNowPlayingInfoPropertyElapsedPlaybackTime +import platform.MediaPlayer.MPNowPlayingInfoPropertyPlaybackRate +import platform.MediaPlayer.MPRemoteCommand +import platform.MediaPlayer.MPRemoteCommandCenter +import platform.MediaPlayer.MPRemoteCommandHandlerStatusCommandFailed +import platform.MediaPlayer.MPRemoteCommandHandlerStatusSuccess + +internal class IosMediaSession { + private val audioSession = AVAudioSession.sharedInstance() + private val nowPlayingInfoCenter = MPNowPlayingInfoCenter.defaultCenter() + private val remoteCommandCenter = MPRemoteCommandCenter.sharedCommandCenter() + + private var configured = false + private val notificationCenter = NSNotificationCenter.defaultCenter() + private val notificationTokens = mutableListOf() + private val remoteCommandTokens = mutableListOf>() + private var onPlay: (() -> Unit)? = null + private var onPause: (() -> Unit)? = null + private var onSeek: ((Double) -> Unit)? = null + private var onNext: (() -> Unit)? = null + private var onPrevious: (() -> Unit)? = null + + fun configure( + onPlay: () -> Unit, + onPause: () -> Unit, + onSeek: (Double) -> Unit, + onNext: () -> Unit, + onPrevious: () -> Unit + ) { + this.onPlay = onPlay + this.onPause = onPause + this.onSeek = onSeek + this.onNext = onNext + this.onPrevious = onPrevious + + if (configured) return + configured = true + + audioSession.setCategory( + category = AVAudioSessionCategoryPlayback, + mode = AVAudioSessionModeDefault, + options = 0u, + error = null) + registerAudioSessionNotifications() + registerRemoteCommands() + } + + fun activate() { + audioSession.setActive(true, error = null) + } + + fun deactivate() { + audioSession.setActive(false, error = null) + } + + fun update( + item: MediaItem?, + positionSeconds: Double, + durationSeconds: Double, + isPlaying: Boolean + ) { + if (item == null) { + nowPlayingInfoCenter.nowPlayingInfo = null + return + } + + val info = NSMutableDictionary() + info.setObject(item.title, forKey = MPMediaItemPropertyTitle) + info.setObject(item.artist, forKey = MPMediaItemPropertyArtist) + item.album.takeIf { it.isNotBlank() }?.let { + info.setObject(it, forKey = MPMediaItemPropertyAlbumTitle) + } + if (durationSeconds.isFinite() && durationSeconds > 0) { + info.setObject(durationSeconds, forKey = MPMediaItemPropertyPlaybackDuration) + } + if (positionSeconds.isFinite() && positionSeconds >= 0) { + info.setObject(positionSeconds, forKey = MPNowPlayingInfoPropertyElapsedPlaybackTime) + } + info.setObject(if (isPlaying) 1.0 else 0.0, forKey = MPNowPlayingInfoPropertyPlaybackRate) + nowPlayingInfoCenter.nowPlayingInfo = info as NSDictionary + } + + fun clear() { + nowPlayingInfoCenter.nowPlayingInfo = null + notificationTokens.forEach { notificationCenter.removeObserver(it) } + notificationTokens.clear() + remoteCommandTokens.forEach { (command, token) -> command.removeTarget(token) } + remoteCommandTokens.clear() + configured = false + onPlay = null + onPause = null + onSeek = null + onNext = null + onPrevious = null + } + + private fun registerAudioSessionNotifications() { + notificationTokens += notificationCenter.addObserverForName( + name = platform.AVFAudio.AVAudioSessionInterruptionNotification, + `object` = audioSession, + queue = NSOperationQueue.mainQueue()) { notification -> + val type = notification?.userInfo?.get( + platform.AVFAudio.AVAudioSessionInterruptionTypeKey) as? Number + if (type?.intValue == platform.AVFAudio.AVAudioSessionInterruptionTypeBegan) { + onPause?.invoke() + } else if (type?.intValue == platform.AVFAudio.AVAudioSessionInterruptionTypeEnded) { + activate() + val options = notification?.userInfo?.get( + platform.AVFAudio.AVAudioSessionInterruptionOptionKey) as? Number + val shouldResume = (options?.intValue ?: 0) and + platform.AVFAudio.AVAudioSessionInterruptionOptionShouldResume.toInt() != 0 + if (shouldResume) onPlay?.invoke() + } + } + } + + private fun registerRemoteCommands() { + remoteCommandTokens += remoteCommandCenter.playCommand to + remoteCommandCenter.playCommand.addTargetWithHandler { + onPlay?.invoke() + MPRemoteCommandHandlerStatusSuccess + } + remoteCommandTokens += remoteCommandCenter.pauseCommand to + remoteCommandCenter.pauseCommand.addTargetWithHandler { + onPause?.invoke() + MPRemoteCommandHandlerStatusSuccess + } + remoteCommandTokens += remoteCommandCenter.changePlaybackPositionCommand to + remoteCommandCenter.changePlaybackPositionCommand.addTargetWithHandler { event -> + val position = (event as? MPChangePlaybackPositionCommandEvent)?.positionTime + if (position != null && position.isFinite() && position >= 0) { + onSeek?.invoke(position) + MPRemoteCommandHandlerStatusSuccess + } else { + MPRemoteCommandHandlerStatusCommandFailed + } + } + remoteCommandTokens += remoteCommandCenter.nextTrackCommand to + remoteCommandCenter.nextTrackCommand.addTargetWithHandler { + onNext?.invoke() + MPRemoteCommandHandlerStatusSuccess + } + remoteCommandTokens += remoteCommandCenter.previousTrackCommand to + remoteCommandCenter.previousTrackCommand.addTargetWithHandler { + onPrevious?.invoke() + MPRemoteCommandHandlerStatusSuccess + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 838df455..278b5a12 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -86,7 +86,7 @@ appdirs = { module = "net.harawata:appdirs", version.ref = "appdirs" } cache4k = { module = "io.github.reactivecircus.cache4k:cache4k", version.ref = "cache4k" } androidx-car-app = { module = "androidx.car.app:app", version.ref = "carApp" } compose-shimmer = { module = "com.valentinilk.shimmer:compose-shimmer", version.ref = "composeShimmer" } -desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" } +desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs_nio", version.ref = "desugar_jdk_libs" } dns-sd-kt = { module = "com.appstractive:dns-sd-kt", version.ref = "dnssdkt" } haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" } haze-materials = { module = "dev.chrisbanes.haze:haze-blur-materials", version.ref = "haze" } diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index 5663e50d..4a484863 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -4,6 +4,10 @@ CADisableMinimumFrameDurationOnPhone + UIBackgroundModes + + audio + CFBundleURLTypes From 13fef429f4045929f953b798368f928f6cd1fa02 Mon Sep 17 00:00:00 2001 From: pgwiz Date: Wed, 28 Oct 2026 08:46:51 +0300 Subject: [PATCH 09/16] fix(player): harden queue resolution, iOS media session controls, endless playback, and download status checks Co-Authored-By: pgwiz --- CHANGELOG.md | 9 +- .../audioplayer/DeviceAudioPlayerQueue.kt | 57 ++++--- .../spotube/core/server/StreamProxy.kt | 6 + .../modules/downloads/DownloadManager.kt | 10 ++ .../spotube/modules/plugin/PluginManager.kt | 3 +- .../audioplayer/DeviceAudioPlayerQueueTest.kt | 145 ++++++++++++++++++ .../core/audioplayer/FakePluginProvider.kt | 53 ++++++- .../core/audioplayer/AudioPlayer.ios.kt | 11 ++ .../core/audioplayer/IosMediaSession.kt | 13 ++ 9 files changed, 270 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c261e85..7c17cda4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,14 +10,15 @@ ### Fixes - **build/android**: Switch `desugar_jdk_libs` to `com.android.tools:desugar_jdk_libs_nio` to resolve Android 9-12 `NoSuchMethodError: java.net.URLEncoder.encode` (PR #3132). -- **server/StreamProxy**: Harden YouTube/GoogleVideo proxy streaming against HTTP 403 Forbidden by inspecting client parameter `c` (`ANDROID`, `IOS`, `VISIONOS`), injecting matching mobile User-Agents, and stripping `Origin` and `Referer` on mobile streams (PR #3150). -- **downloads/DownloadManager**: Prevent HTTP 403 Forbidden on YouTube audio downloads by injecting matching client User-Agents and using `Range: bytes=0-0` byte-range probes instead of bare `HEAD` requests (PR #3150). +- **server/StreamProxy**: Harden YouTube/GoogleVideo proxy streaming against HTTP 403 Forbidden by inspecting client parameter `c` (`ANDROID`, `IOS`, `VISIONOS`), injecting matching mobile User-Agents, and stripping both `Origin` and `Referer`/`Referrer` headers on mobile streams (PR #3150). +- **downloads/DownloadManager**: Prevent HTTP 403 Forbidden on YouTube audio downloads by injecting matching client User-Agents, stripping `Origin` and `Referer`/`Referrer` headers on mobile streams, and failing early on non-success HTTP probe status to prevent corrupt HTML error pages from saving into the media library (PR #3150). +- **audioplayer/DeviceAudioPlayerQueue**: Pair `QueueEntry` with resolved `MediaItem` in queue mutations to prevent quadratic network re-resolutions and null pointer crashes; read queue state via `getQueue()` to ensure endless playback succeeds when backgrounded; deduplicate radio mix seed tracks to properly cascade to album and artist top tracks (PR #3092, PR #3134). +- **audioplayer/ios**: Update lock screen and Control Center playback rates immediately in `pause()` and `syncTimeControlStatus()` to fix stuck playing indicators; register `togglePlayPauseCommand` handler for Bluetooth headphone and AirPods single-tap toggle controls (PR #3170). +- **plugin/PluginManager**: Increment generation counter in `removePlugin` to trigger clean reactive flow restarts and reliable fallback activation upon plugin uninstallation (PR #3147). - **server/StreamingUrlRepository**: Evict stale cached track sources from `MatchedTracksRepository` when cached source resolution fails before falling back to fresh track lookups (PR #3092, PR #3134). -- **server/StreamProxy**: Prevent GoogleVideo CDN HTTP 403 Forbidden by injecting `Referer: https://www.youtube.com/`, `Origin: https://www.youtube.com`, and browser `User-Agent` headers on `googlevideo.com`, `youtube.com`, and `youtu.be` proxy requests, preserving stream range headers (`Range`, `If-Range`) without header duplication, and filtering hop-by-hop headers. - **newpipe/RealNewPipeAudioAPI**: Fix YouTube video ID extraction when `track.externalUri` contains YouTube URLs (`v=`, `youtu.be`, `/embed/`, `/shorts/`, with trailing slash/query/fragment support) instead of passing raw Spotify track IDs to video info lookups. - **newpipe/RealNewPipeAudioAPI**: Improve track matching and scoring for Topic channels, VEVO uploads, and official variants with relaxed duration bins, and guarantee top relevance search result is prioritized at index 0 when confidence scoring yields no candidates $\ge 0.3$. - **newpipe/NewPipeService**: Implement native YouTube radio mix extractor (`RD{videoId}`) across commonMain, androidJvmMain, and iosMain platforms with safe thumbnail fallbacks. -- **audioplayer/DeviceAudioPlayerQueue**: Add native YouTube radio mix fallback for endless playback queue continuation when metadata recommendations fail or are unauthenticated, supporting complete YouTube URL patterns. ## [6.0.0-beta.0](https://github.com/team-spotube/spotube/compare/v5.1.2...v6.0.0-beta.0) (2026-10-26) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt index 268b5aab..4c987325 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt @@ -137,14 +137,15 @@ class DeviceAudioPlayerQueue( startPosition: Int, collectionEntry: QueueCollectionEntry?) { logger.i { "Loading queue with ${entries.size} entries, autoPlay=$autoPlay, startPosition=$startPosition" } - val mediaItems = entries.mapNotNull { it.toMediaItem() } - if (mediaItems.isEmpty()) { + val paired = entries.mapNotNull { entry -> entry.toMediaItem()?.let { mediaItem -> mediaItem to entry } } + if (paired.isEmpty()) { logger.w { "load: no valid media items after filtering blanks" } } + val mediaItems = paired.map { it.first } val normalizedStartPosition = if (mediaItems.isEmpty()) 0 else startPosition.coerceIn(0, mediaItems.lastIndex) logger.d { "Normalized start position: $startPosition -> $normalizedStartPosition" } - entryByMediaUrl.value = mediaItems.associate { it.url to entries.firstOrNull { e -> e.toMediaItem()?.url == it.url }!! } + entryByMediaUrl.value = paired.associate { (mediaItem, entry) -> mediaItem.url to entry } updateCollectionContext(collectionEntry) audioPlayer.load(mediaItems, autoPlay, normalizedStartPosition) persistQueueStateIfReady() @@ -170,16 +171,14 @@ class DeviceAudioPlayerQueue( } logger.i { "Adding ${entries.size} entries to queue" } - val mediaItems = entries.mapNotNull { it.toMediaItem() } - if (mediaItems.isEmpty()) { + val paired = entries.mapNotNull { entry -> entry.toMediaItem()?.let { mediaItem -> mediaItem to entry } } + if (paired.isEmpty()) { logger.w { "addAllToQueue: all entries had blank urls, skipping" } return } - val validEntries = entries.zip(mediaItems).map { (entry) -> - entry - } - entryByMediaUrl.update { - it + mediaItems.zip(validEntries).associate { (mediaItem, entry) -> mediaItem.url to entry } + val mediaItems = paired.map { it.first } + entryByMediaUrl.update { current -> + current + paired.associate { (mediaItem, entry) -> mediaItem.url to entry } } updateCollectionContext(collectionEntry) @@ -199,14 +198,14 @@ class DeviceAudioPlayerQueue( } logger.i { "Adding ${entries.size} entries after current position" } - val mediaItems = entries.mapNotNull { it.toMediaItem() } - if (mediaItems.isEmpty()) { + val paired = entries.mapNotNull { entry -> entry.toMediaItem()?.let { mediaItem -> mediaItem to entry } } + if (paired.isEmpty()) { logger.w { "addAllAfterCurrent: all entries had blank urls, skipping" } return } - val validEntries = entries.zip(mediaItems).map { (entry) -> entry } - entryByMediaUrl.update { - it + mediaItems.zip(validEntries).associate { (mediaItem, entry) -> mediaItem.url to entry } + val mediaItems = paired.map { it.first } + entryByMediaUrl.update { current -> + current + paired.associate { (mediaItem, entry) -> mediaItem.url to entry } } mediaItems.forEach { mediaItem -> @@ -218,7 +217,9 @@ class DeviceAudioPlayerQueue( } override suspend fun removeFromQueue(entry: QueueEntry) { - val mediaUrl = entry.toMediaItem()?.url ?: return + val mediaUrl = entryByMediaUrl.value.entries.firstOrNull { it.value == entry }?.key + ?: entry.toMediaItem()?.url + ?: return removeFromQueueByMediaUrl(mediaUrl) } @@ -323,7 +324,7 @@ class DeviceAudioPlayerQueue( val settings = settingsProvider.settingsState.value ?: return if (!settings.enableEndlessPlayback) return - val queue = queueFlow.value + val queue = getQueue() val streamingTracks = queue.filterIsInstance() if (streamingTracks.isEmpty()) return @@ -359,23 +360,20 @@ class DeviceAudioPlayerQueue( val videoId = if (seed.id.matches(Regex("^[a-zA-Z0-9_-]{11}$"))) { seed.id } else { - val extracted = seed.externalUri?.let { uri -> + seed.externalUri?.let { uri -> Regex("""[?&]v=([a-zA-Z0-9_-]{11})(?:[&#]|$)""").find(uri)?.groupValues?.get(1) ?: Regex("""youtu\.be/([a-zA-Z0-9_-]{11})(?:[?#/]|$)""").find(uri)?.groupValues?.get(1) ?: Regex("""youtube\.com/(?:embed|v|shorts)/([a-zA-Z0-9_-]{11})(?:[?#/]|$)""").find(uri)?.groupValues?.get(1) } - extracted ?: runCatching { - val artist = seed.artists.firstOrNull()?.name.orEmpty() - newPipeService.searchVideos("${seed.title} $artist".trim(), SearchKind.Songs) - .firstOrNull()?.id - }.getOrNull() } if (!videoId.isNullOrBlank()) { val radioMix = runCatching { newPipeService.getRadioMix(videoId) }.getOrDefault(emptyList()) - if (radioMix.isNotEmpty()) { - logger.i { "Endless playback: got ${radioMix.size} tracks from YouTube radio mix for video $videoId" } - recommendations = radioMix.map { video -> + val existingTrackIds = queue.filterIsInstance().map { it.track.id }.toSet() + val newRadioTracks = radioMix.filter { it.id !in existingTrackIds && it.id != videoId } + if (newRadioTracks.isNotEmpty()) { + logger.i { "Endless playback: got ${newRadioTracks.size} new tracks from YouTube radio mix for video $videoId" } + recommendations = newRadioTracks.map { video -> MetadataTrack( id = video.id, title = video.title, @@ -498,12 +496,13 @@ class DeviceAudioPlayerQueue( return } - val mediaItems = state.entries.mapNotNull { it.toMediaItem() } - if (mediaItems.isEmpty()) { + val paired = state.entries.mapNotNull { entry -> entry.toMediaItem()?.let { mediaItem -> mediaItem to entry } } + if (paired.isEmpty()) { logger.w { "restorePersistedState: all entries had blank urls" } return } - entryByMediaUrl.value = mediaItems.associate { it.url to state.entries.firstOrNull { e -> e.toMediaItem()?.url == it.url }!! } + val mediaItems = paired.map { it.first } + entryByMediaUrl.value = paired.associate { (mediaItem, entry) -> mediaItem.url to entry } val normalizedStartPosition = state.currentIndex.coerceIn(0, mediaItems.lastIndex) logger.d { "Loading restored playlist with normalized start position: ${state.currentIndex} -> $normalizedStartPosition" } audioPlayer.load(mediaItems, autoPlay = false, startPosition = normalizedStartPosition) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamProxy.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamProxy.kt index b510fcbc..83fad135 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamProxy.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamProxy.kt @@ -612,17 +612,23 @@ internal class StreamProxy( "ANDROID" -> { builder.headers[HttpHeaders.UserAgent] = ANDROID_USER_AGENT builder.headers.remove(HttpHeaders.Referrer) + builder.headers.remove("Referer") builder.headers.remove(HttpHeaders.Origin) + builder.headers.remove("Origin") } "IOS" -> { builder.headers[HttpHeaders.UserAgent] = IOS_USER_AGENT builder.headers.remove(HttpHeaders.Referrer) + builder.headers.remove("Referer") builder.headers.remove(HttpHeaders.Origin) + builder.headers.remove("Origin") } "VISIONOS" -> { builder.headers[HttpHeaders.UserAgent] = VISIONOS_USER_AGENT builder.headers.remove(HttpHeaders.Referrer) + builder.headers.remove("Referer") builder.headers.remove(HttpHeaders.Origin) + builder.headers.remove("Origin") } else -> { builder.headers[HttpHeaders.Referrer] = YOUTUBE_REFERER diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadManager.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadManager.kt index a5f71c2f..79013aa3 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadManager.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadManager.kt @@ -462,6 +462,10 @@ class DownloadManager( } applyClientHeaders(url) } + if (!response.status.isSuccess() && response.status != HttpStatusCode.PartialContent) { + logger.w { "[Download $id] PROBE returned HTTP ${response.status.value}; failing download early" } + throw DownloadException(getString(Res.string.downloads_error_http, response.status.value)) + } val contentRange = response.headers[HttpHeaders.ContentRange] val totalFromContentRange = contentRange?.substringAfterLast('/')?.trim()?.toLongOrNull() val contentLength = totalFromContentRange ?: response.contentLength() ?: 0L @@ -514,17 +518,23 @@ class DownloadManager( "ANDROID" -> { headers[HttpHeaders.UserAgent] = ANDROID_USER_AGENT headers.remove(HttpHeaders.Referrer) + headers.remove("Referer") headers.remove(HttpHeaders.Origin) + headers.remove("Origin") } "IOS" -> { headers[HttpHeaders.UserAgent] = IOS_USER_AGENT headers.remove(HttpHeaders.Referrer) + headers.remove("Referer") headers.remove(HttpHeaders.Origin) + headers.remove("Origin") } "VISIONOS" -> { headers[HttpHeaders.UserAgent] = VISIONOS_USER_AGENT headers.remove(HttpHeaders.Referrer) + headers.remove("Referer") headers.remove(HttpHeaders.Origin) + headers.remove("Origin") } else -> { headers[HttpHeaders.Referrer] = YOUTUBE_REFERER diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginManager.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginManager.kt index 49b9b4ab..564fbddf 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginManager.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginManager.kt @@ -616,8 +616,9 @@ class PluginManager( val newState = PluginManagerStates.Data( plugins = updatedPlugins, selectedPlugins = updatedSelectedPlugins, - generation = currentState.generation + generation = currentState.generation + 1 ) + logger.d { "removePlugin: bumped generation to ${newState.generation} after removing ${plugin.id}" } withContext(Dispatchers.IO) { val pluginDir = pluginsDir / plugin.id.toPath() diff --git a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueueTest.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueueTest.kt index 5dde1c65..bb3ad2b3 100644 --- a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueueTest.kt +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueueTest.kt @@ -494,4 +494,149 @@ class DeviceAudioPlayerQueueTest { assertEquals("Fallback", local.name) assertEquals(listOf("Artist1", "Artist2"), local.artists) } + + private suspend fun waitUntil(maxAttempts: Int = 100, condition: suspend () -> Boolean) { + var attempts = 0 + while (attempts < maxAttempts) { + if (condition()) return + withContext(Dispatchers.IO) { + Thread.sleep(25) + } + attempts++ + } + assertTrue { condition() } + } + + // --- endless playback --- + + @Test + fun `endless playback appends recommendations when queue finishes`() = runTest { + val recTrack = streamingTrack("rec1") + val service = FakePluginService( + metadataTrackAPI = FakeMetadataTrackAPI(recommendations = listOf(recTrack)) + ) + fakePlugin.setPlugin(service) + fakeSettings.setSettings( + org.spotube.spotube.modules.settings.UserSettings( + playbackProxyServerPort = 14769, + enableEndlessPlayback = true + ) + ) + + val queue = createQueue() + val seed = streamingEntry("t1") + queue.load(listOf(seed)) + + waitUntil { + queue.getQueue().any { (it as? QueueEntry.StreamingTrack)?.track?.id == "rec1" } + } + } + + @Test + fun `endless playback cascades to album tracks when recommendations empty`() = runTest { + val albumTrack = streamingTrack("album-track-1") + val service = FakePluginService( + metadataTrackAPI = FakeMetadataTrackAPI(recommendations = emptyList()), + metadataAlbumAPI = FakeMetadataAlbumAPI( + albumTracks = mapOf("album-t1" to listOf(streamingTrack("t1"), albumTrack)) + ) + ) + fakePlugin.setPlugin(service) + fakeSettings.setSettings( + org.spotube.spotube.modules.settings.UserSettings( + playbackProxyServerPort = 14769, + enableEndlessPlayback = true + ) + ) + + val queue = createQueue() + val seed = streamingEntry("t1") + queue.load(listOf(seed)) + + waitUntil { + queue.getQueue().any { (it as? QueueEntry.StreamingTrack)?.track?.id == "album-track-1" } + } + val queueTracks = queue.getQueue() + assertEquals(1, queueTracks.count { (it as? QueueEntry.StreamingTrack)?.track?.id == "t1" }) + } + + @Test + fun `endless playback cascades to artist top tracks when album tracks empty`() = runTest { + val artistTrack = streamingTrack("top-track-1") + val service = FakePluginService( + metadataTrackAPI = FakeMetadataTrackAPI(recommendations = emptyList()), + metadataAlbumAPI = FakeMetadataAlbumAPI(albumTracks = emptyMap()), + metadataArtistAPI = FakeMetadataArtistAPI( + topTracks = mapOf("artist-t1" to listOf(streamingTrack("t1"), artistTrack)) + ) + ) + fakePlugin.setPlugin(service) + fakeSettings.setSettings( + org.spotube.spotube.modules.settings.UserSettings( + playbackProxyServerPort = 14769, + enableEndlessPlayback = true + ) + ) + + val queue = createQueue() + val seed = streamingEntry("t1") + queue.load(listOf(seed)) + + waitUntil { + queue.getQueue().any { (it as? QueueEntry.StreamingTrack)?.track?.id == "top-track-1" } + } + val queueTracks = queue.getQueue() + assertEquals(1, queueTracks.count { (it as? QueueEntry.StreamingTrack)?.track?.id == "t1" }) + } + + @Test + fun `endless playback filters out blacklisted tracks`() = runTest { + val recTrack = streamingTrack("rec1") + fakeBlacklist.toggleTrack(recTrack) + + val service = FakePluginService( + metadataTrackAPI = FakeMetadataTrackAPI(recommendations = listOf(recTrack)) + ) + fakePlugin.setPlugin(service) + fakeSettings.setSettings( + org.spotube.spotube.modules.settings.UserSettings( + playbackProxyServerPort = 14769, + enableEndlessPlayback = true + ) + ) + + val queue = createQueue() + val seed = streamingEntry("t1") + queue.load(listOf(seed)) + + withContext(Dispatchers.IO) { Thread.sleep(200) } + + val queueTracks = queue.getQueue() + assertTrue { queueTracks.none { (it as? QueueEntry.StreamingTrack)?.track?.id == "rec1" } } + } + + @Test + fun `endless playback does not fetch when setting is disabled`() = runTest { + val recTrack = streamingTrack("rec1") + val service = FakePluginService( + metadataTrackAPI = FakeMetadataTrackAPI(recommendations = listOf(recTrack)) + ) + fakePlugin.setPlugin(service) + fakeSettings.setSettings( + org.spotube.spotube.modules.settings.UserSettings( + playbackProxyServerPort = 14769, + enableEndlessPlayback = false + ) + ) + + val queue = createQueue() + val seed = streamingEntry("t1") + queue.load(listOf(seed)) + + withContext(Dispatchers.IO) { Thread.sleep(200) } + + val queueTracks = queue.getQueue() + assertEquals(1, queueTracks.size) + assertEquals("t1", (queueTracks[0] as? QueueEntry.StreamingTrack)?.track?.id) + } } diff --git a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/FakePluginProvider.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/FakePluginProvider.kt index b3daab34..8a922a03 100644 --- a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/FakePluginProvider.kt +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/FakePluginProvider.kt @@ -1,14 +1,22 @@ package org.spotube.spotube.core.audioplayer +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistOverview import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI import org.spotube.spotube.core.zipline.PluginService import org.spotube.spotube.core.zipline.PluginServiceScope import org.spotube.spotube.modules.plugin.PluginProvider +import app.cash.zipline.ZiplineService import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlin.reflect.KClass class FakeMetadataTrackAPI( private val recommendations: List = emptyList() @@ -32,8 +40,44 @@ class FakeMetadataTrackAPI( ): List = recommendations } +class FakeMetadataAlbumAPI( + private val albumTracks: Map> = emptyMap() +) : MetadataAlbumAPI { + override suspend fun getAlbum(id: String): MetadataAlbum.Detailed = error("not mocked") + override suspend fun getTrackAlbum(track: MetadataTrack): MetadataAlbum.Detailed = error("not mocked") + override suspend fun getAlbumTracks(id: String, pagination: PaginationStrategy?): PaginationResult { + val tracks = albumTracks[id] ?: emptyList() + return PaginationResult( + items = tracks, + totalCount = tracks.size, + nextPagination = null + ) + } + override suspend fun savedAlbums(pagination: PaginationStrategy?): PaginationResult = error("not mocked") + override suspend fun isSavedAlbums(ids: List): List = error("not mocked") + override suspend fun saveAlbums(ids: List) = error("not mocked") + override suspend fun removeSavedAlbums(ids: List) = error("not mocked") +} + +class FakeMetadataArtistAPI( + private val topTracks: Map> = emptyMap() +) : MetadataArtistAPI { + override suspend fun getArtist(id: String): MetadataArtist.Detailed = error("not mocked") + override suspend fun artistOverview(id: String): MetadataArtistOverview = error("not mocked") + override suspend fun getArtistTop10Tracks(id: String): List = topTracks[id] ?: emptyList() + override suspend fun relatedArtists(id: String, pagination: PaginationStrategy?): PaginationResult = error("not mocked") + override suspend fun featuredPlaylists(id: String, pagination: PaginationStrategy?): PaginationResult = error("not mocked") + override suspend fun getArtistAlbums(id: String, pagination: PaginationStrategy?): PaginationResult = error("not mocked") + override suspend fun savedArtists(pagination: PaginationStrategy?): PaginationResult = error("not mocked") + override suspend fun isSavedArtists(ids: List): List = error("not mocked") + override suspend fun saveArtists(ids: List) = error("not mocked") + override suspend fun removeSavedArtists(ids: List) = error("not mocked") +} + class FakePluginService( private val metadataTrackAPI: MetadataTrackAPI = FakeMetadataTrackAPI(), + private val metadataAlbumAPI: MetadataAlbumAPI? = null, + private val metadataArtistAPI: MetadataArtistAPI? = null, override val pluginId: String = "fake-plugin") : PluginService { override val loggedInFlow: StateFlow = MutableStateFlow(true) @@ -41,9 +85,12 @@ class FakePluginService( override suspend fun stop() = Unit override suspend fun use(block: suspend PluginServiceScope.() -> T): T { - val scope = PluginServiceScope( - mapOf(MetadataTrackAPI::class to metadataTrackAPI) - ) + val registry = buildMap, ZiplineService> { + put(MetadataTrackAPI::class, metadataTrackAPI) + metadataAlbumAPI?.let { put(MetadataAlbumAPI::class, it) } + metadataArtistAPI?.let { put(MetadataArtistAPI::class, it) } + } + val scope = PluginServiceScope(registry) return scope.block() } } diff --git a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.ios.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.ios.kt index 2b5d4c4c..2827ceca 100644 --- a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.ios.kt +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.ios.kt @@ -155,6 +155,12 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface if (avPlayer.currentItem == null) { _playerState.tryEmit(PlayerState.IDLE) } + + mediaSession.update( + item = _currentMediaItem.value, + positionSeconds = _position.value.inWholeMilliseconds / 1000.0, + durationSeconds = _duration.value.inWholeMilliseconds / 1000.0, + isPlaying = status == AVPlayerTimeControlStatusPlaying) } private fun resolveState(): PlayerState { @@ -178,6 +184,11 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface actual override suspend fun pause() { avPlayer.pause() + mediaSession.update( + item = _currentMediaItem.value, + positionSeconds = _position.value.inWholeMilliseconds / 1000.0, + durationSeconds = _duration.value.inWholeMilliseconds / 1000.0, + isPlaying = false) } actual override suspend fun stop() { diff --git a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/IosMediaSession.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/IosMediaSession.kt index 18b021fb..d4bbeee1 100644 --- a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/IosMediaSession.kt +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/IosMediaSession.kt @@ -55,6 +55,8 @@ internal class IosMediaSession { private var onNext: (() -> Unit)? = null private var onPrevious: (() -> Unit)? = null + private var lastIsPlaying = false + fun configure( onPlay: () -> Unit, onPause: () -> Unit, @@ -112,10 +114,12 @@ internal class IosMediaSession { info.setObject(positionSeconds, forKey = MPNowPlayingInfoPropertyElapsedPlaybackTime) } info.setObject(if (isPlaying) 1.0 else 0.0, forKey = MPNowPlayingInfoPropertyPlaybackRate) + lastIsPlaying = isPlaying nowPlayingInfoCenter.nowPlayingInfo = info as NSDictionary } fun clear() { + lastIsPlaying = false nowPlayingInfoCenter.nowPlayingInfo = null notificationTokens.forEach { notificationCenter.removeObserver(it) } notificationTokens.clear() @@ -160,6 +164,15 @@ internal class IosMediaSession { onPause?.invoke() MPRemoteCommandHandlerStatusSuccess } + remoteCommandTokens += remoteCommandCenter.togglePlayPauseCommand to + remoteCommandCenter.togglePlayPauseCommand.addTargetWithHandler { + if (lastIsPlaying) { + onPause?.invoke() + } else { + onPlay?.invoke() + } + MPRemoteCommandHandlerStatusSuccess + } remoteCommandTokens += remoteCommandCenter.changePlaybackPositionCommand to remoteCommandCenter.changePlaybackPositionCommand.addTargetWithHandler { event -> val position = (event as? MPChangePlaybackPositionCommandEvent)?.positionTime From 02d903e5ed54e7008aba6d076cc4956660f8cefe Mon Sep 17 00:00:00 2001 From: pgwiz Date: Wed, 28 Oct 2026 08:58:03 +0300 Subject: [PATCH 10/16] ci: add fallback keystore generation for multiplatform release workflow Co-Authored-By: pgwiz --- .github/workflows/spotube-release-binary.yml | 27 +++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index f12a0d2e..19b13a43 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -105,25 +105,28 @@ jobs: - name: Make gradlew executable run: chmod +x gradlew - - name: Decode keystore - env: - KEYSTORE: ${{ secrets.KEYSTORE }} - run: | - echo "$KEYSTORE" | base64 --decode > "${{ github.workspace }}/composeApp/upload-keystore.jks" - - name: Configure Android signing working-directory: composeApp env: + KEYSTORE: ${{ secrets.KEYSTORE }} KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} KEYSTORE_KEY_ALIAS: ${{ secrets.KEYSTORE_KEY_ALIAS }} KEYSTORE_KEY_PASSWORD: ${{ secrets.KEYSTORE_KEY_PASSWORD }} run: | - cat >> local.properties < "${{ github.workspace }}/composeApp/upload-keystore.jks" + echo "release.signing.storeFile=${{ github.workspace }}/composeApp/upload-keystore.jks" >> local.properties + echo "release.signing.storePassword=${KEYSTORE_PASSWORD}" >> local.properties + echo "release.signing.keyAlias=${KEYSTORE_KEY_ALIAS}" >> local.properties + echo "release.signing.keyPassword=${KEYSTORE_KEY_PASSWORD}" >> local.properties + else + echo "No custom keystore secret found, generating build keystore..." + keytool -genkey -v -keystore "${{ github.workspace }}/composeApp/upload-keystore.jks" -alias spotube -keyalg RSA -keysize 2048 -validity 10000 -storepass spotubepass -keypass spotubepass -dname "CN=Spotube, OU=Mobile, O=Spotube, C=US" + echo "release.signing.storeFile=${{ github.workspace }}/composeApp/upload-keystore.jks" >> local.properties + echo "release.signing.storePassword=spotubepass" >> local.properties + echo "release.signing.keyAlias=spotube" >> local.properties + echo "release.signing.keyPassword=spotubepass" >> local.properties + fi - name: Assemble Release run: | From 7254a8e35d2fccc79797962847a955c6f26845ad Mon Sep 17 00:00:00 2001 From: pgwiz Date: Wed, 28 Oct 2026 10:05:38 +0300 Subject: [PATCH 11/16] fix(ios): resolve compileKotlinIosArm64 type mismatches in IosMediaSession and stub getRadioMix Co-Authored-By: pgwiz --- .../core/audioplayer/IosMediaSession.kt | 65 +++++++++++-------- .../core/newpipe/NewPipeService.ios.kt | 25 +------ 2 files changed, 40 insertions(+), 50 deletions(-) diff --git a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/IosMediaSession.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/IosMediaSession.kt index d4bbeee1..17eb9393 100644 --- a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/IosMediaSession.kt +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/IosMediaSession.kt @@ -23,8 +23,7 @@ import kotlinx.cinterop.ExperimentalForeignApi import platform.AVFAudio.AVAudioSession import platform.AVFAudio.AVAudioSessionCategoryPlayback import platform.AVFAudio.AVAudioSessionModeDefault -import platform.Foundation.NSDictionary -import platform.Foundation.NSMutableDictionary +import platform.AVFAudio.AVAudioSessionSetActiveOptionNotifyOthersOnDeactivation import platform.Foundation.NSNotificationCenter import platform.Foundation.NSOperationQueue import platform.MediaPlayer.MPChangePlaybackPositionCommandEvent @@ -73,21 +72,32 @@ internal class IosMediaSession { if (configured) return configured = true - audioSession.setCategory( - category = AVAudioSessionCategoryPlayback, - mode = AVAudioSessionModeDefault, - options = 0u, - error = null) + runCatching { + audioSession.setCategory( + category = AVAudioSessionCategoryPlayback, + mode = AVAudioSessionModeDefault, + options = 0u, + error = null + ) + } registerAudioSessionNotifications() registerRemoteCommands() } fun activate() { - audioSession.setActive(true, error = null) + runCatching { + audioSession.setActive(active = true, withOptions = 0u, error = null) + } } fun deactivate() { - audioSession.setActive(false, error = null) + runCatching { + audioSession.setActive( + active = false, + withOptions = AVAudioSessionSetActiveOptionNotifyOthersOnDeactivation, + error = null + ) + } } fun update( @@ -101,21 +111,21 @@ internal class IosMediaSession { return } - val info = NSMutableDictionary() - info.setObject(item.title, forKey = MPMediaItemPropertyTitle) - info.setObject(item.artist, forKey = MPMediaItemPropertyArtist) + val info = mutableMapOf() + info[MPMediaItemPropertyTitle] = item.title + info[MPMediaItemPropertyArtist] = item.artist item.album.takeIf { it.isNotBlank() }?.let { - info.setObject(it, forKey = MPMediaItemPropertyAlbumTitle) + info[MPMediaItemPropertyAlbumTitle] = it } if (durationSeconds.isFinite() && durationSeconds > 0) { - info.setObject(durationSeconds, forKey = MPMediaItemPropertyPlaybackDuration) + info[MPMediaItemPropertyPlaybackDuration] = durationSeconds } if (positionSeconds.isFinite() && positionSeconds >= 0) { - info.setObject(positionSeconds, forKey = MPNowPlayingInfoPropertyElapsedPlaybackTime) + info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = positionSeconds } - info.setObject(if (isPlaying) 1.0 else 0.0, forKey = MPNowPlayingInfoPropertyPlaybackRate) + info[MPNowPlayingInfoPropertyPlaybackRate] = if (isPlaying) 1.0 else 0.0 lastIsPlaying = isPlaying - nowPlayingInfoCenter.nowPlayingInfo = info as NSDictionary + nowPlayingInfoCenter.nowPlayingInfo = info } fun clear() { @@ -137,17 +147,20 @@ internal class IosMediaSession { notificationTokens += notificationCenter.addObserverForName( name = platform.AVFAudio.AVAudioSessionInterruptionNotification, `object` = audioSession, - queue = NSOperationQueue.mainQueue()) { notification -> - val type = notification?.userInfo?.get( - platform.AVFAudio.AVAudioSessionInterruptionTypeKey) as? Number - if (type?.intValue == platform.AVFAudio.AVAudioSessionInterruptionTypeBegan) { + queue = NSOperationQueue.mainQueue() + ) { notification -> + val type = (notification?.userInfo?.get( + platform.AVFAudio.AVAudioSessionInterruptionTypeKey + ) as? Number)?.toLong()?.toULong() + if (type == platform.AVFAudio.AVAudioSessionInterruptionTypeBegan) { onPause?.invoke() - } else if (type?.intValue == platform.AVFAudio.AVAudioSessionInterruptionTypeEnded) { + } else if (type == platform.AVFAudio.AVAudioSessionInterruptionTypeEnded) { activate() - val options = notification?.userInfo?.get( - platform.AVFAudio.AVAudioSessionInterruptionOptionKey) as? Number - val shouldResume = (options?.intValue ?: 0) and - platform.AVFAudio.AVAudioSessionInterruptionOptionShouldResume.toInt() != 0 + val options = (notification?.userInfo?.get( + platform.AVFAudio.AVAudioSessionInterruptionOptionKey + ) as? Number)?.toLong()?.toULong() ?: 0uL + val shouldResume = (options and + platform.AVFAudio.AVAudioSessionInterruptionOptionShouldResume) != 0uL if (shouldResume) onPlay?.invoke() } } diff --git a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.ios.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.ios.kt index 63491598..75d46a2d 100644 --- a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.ios.kt +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.ios.kt @@ -88,29 +88,6 @@ actual class NewPipeService { } actual suspend fun getRadioMix(videoId: String): List { - return withContext(Dispatchers.IO) { - runCatching { - val extractor = YouTube.getPlaylistExtractor("https://www.youtube.com/watch?v=$videoId&list=RD$videoId") - extractor.fetchPage() - val results = extractor.initialPage - results.items.mapNotNull { item -> - if (item is StreamInfoItem) { - VideoSearchResult( - title = item.name, - url = item.url, - uploader = item.uploaderName ?: "Unknown", - durationMs = secondsToMs(item.duration), - thumbnailUrl = item.thumbnails.firstOrNull()?.url.orEmpty(), - id = runCatching { Url(item.url).parameters["v"] }.getOrNull() - ?: item.url.substringAfter("watch?v=", "").substringBefore("&") - .takeIf { it.isNotEmpty() } - ?: item.url.substringAfterLast("/") - ) - } else { - null - } - } - }.getOrDefault(emptyList()) - } + return emptyList() } } \ No newline at end of file From dd098aea0a2efcd85ef39724d8f162d9a183916b Mon Sep 17 00:00:00 2001 From: pgwiz Date: Wed, 28 Oct 2026 10:16:33 +0300 Subject: [PATCH 12/16] fix(ios): remove manual setActive calls in IosMediaSession Co-Authored-By: pgwiz --- .../spotube/core/audioplayer/IosMediaSession.kt | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/IosMediaSession.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/IosMediaSession.kt index 17eb9393..aac4e435 100644 --- a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/IosMediaSession.kt +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/IosMediaSession.kt @@ -23,7 +23,6 @@ import kotlinx.cinterop.ExperimentalForeignApi import platform.AVFAudio.AVAudioSession import platform.AVFAudio.AVAudioSessionCategoryPlayback import platform.AVFAudio.AVAudioSessionModeDefault -import platform.AVFAudio.AVAudioSessionSetActiveOptionNotifyOthersOnDeactivation import platform.Foundation.NSNotificationCenter import platform.Foundation.NSOperationQueue import platform.MediaPlayer.MPChangePlaybackPositionCommandEvent @@ -85,19 +84,11 @@ internal class IosMediaSession { } fun activate() { - runCatching { - audioSession.setActive(active = true, withOptions = 0u, error = null) - } + // AVPlayer automatically activates the shared audio session on play. } fun deactivate() { - runCatching { - audioSession.setActive( - active = false, - withOptions = AVAudioSessionSetActiveOptionNotifyOthersOnDeactivation, - error = null - ) - } + // AVPlayer automatically manages session deactivation when paused or stopped. } fun update( From 91ea775b77ffdd3f706daa6b1cde21f202e884a5 Mon Sep 17 00:00:00 2001 From: pgwiz Date: Wed, 28 Oct 2026 10:40:04 +0300 Subject: [PATCH 13/16] ci: allow release platforms to run independently with presets and tolerant publishing Co-Authored-By: pgwiz --- .github/workflows/spotube-release-binary.yml | 76 ++++++++++++++++++-- 1 file changed, 72 insertions(+), 4 deletions(-) diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index 19b13a43..9aa39933 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -12,6 +12,52 @@ on: - stable - beta - nightly + target_preset: + description: 'Target preset' + required: true + type: choice + default: 'all' + options: + - all + - desktop + - desktop-no-mac + - mobile + - windows + - linux + - macos + - android + - ios + - custom + build_windows: + description: ' -> Windows x86_64 (.exe) (if custom)' + required: false + type: boolean + default: true + build_linux: + description: ' -> Linux x86_64 (.deb, .rpm, .AppImage, .tar.xz) (if custom)' + required: false + type: boolean + default: true + build_linux_arm64: + description: ' -> Linux ARM64 (.deb, .rpm, .AppImage, .tar.xz) (if custom)' + required: false + type: boolean + default: true + build_macos: + description: ' -> macOS Universal (.dmg) (if custom)' + required: false + type: boolean + default: true + build_android: + description: ' -> Android (.apk) (if custom)' + required: false + type: boolean + default: true + build_ios: + description: ' -> iOS (.ipa) (if custom)' + required: false + type: boolean + default: true dry_run: description: 'Dry run (build & upload artifacts only, skip creating release)' required: false @@ -78,6 +124,7 @@ jobs: build-android: name: Android + if: ${{ inputs.target_preset == 'all' || inputs.target_preset == 'mobile' || inputs.target_preset == 'android' || inputs.target_preset == '' || (inputs.target_preset == 'custom' && inputs.build_android) }} needs: prepare-deps timeout-minutes: 120 runs-on: ubuntu-latest @@ -155,6 +202,7 @@ jobs: build-linux: name: Linux Desktop + if: ${{ inputs.target_preset == 'all' || inputs.target_preset == 'desktop' || inputs.target_preset == 'desktop-no-mac' || inputs.target_preset == 'linux' || inputs.target_preset == '' || (inputs.target_preset == 'custom' && inputs.build_linux) }} needs: prepare-deps timeout-minutes: 120 runs-on: ubuntu-latest @@ -268,6 +316,7 @@ jobs: build-linux-arm64: name: Linux Desktop (aarch64) + if: ${{ inputs.target_preset == 'all' || inputs.target_preset == 'desktop' || inputs.target_preset == 'desktop-no-mac' || inputs.target_preset == 'linux' || inputs.target_preset == '' || (inputs.target_preset == 'custom' && inputs.build_linux_arm64) }} needs: prepare-deps timeout-minutes: 120 runs-on: ubuntu-24.04-arm @@ -388,6 +437,7 @@ jobs: build-windows: name: Windows Desktop + if: ${{ inputs.target_preset == 'all' || inputs.target_preset == 'desktop' || inputs.target_preset == 'desktop-no-mac' || inputs.target_preset == 'windows' || inputs.target_preset == '' || (inputs.target_preset == 'custom' && inputs.build_windows) }} needs: prepare-deps timeout-minutes: 120 runs-on: windows-latest @@ -451,6 +501,7 @@ jobs: build-macos: name: macOS Desktop + if: ${{ inputs.target_preset == 'all' || inputs.target_preset == 'desktop' || inputs.target_preset == 'macos' || inputs.target_preset == '' || (inputs.target_preset == 'custom' && inputs.build_macos) }} needs: prepare-deps timeout-minutes: 120 runs-on: macos-latest @@ -519,6 +570,7 @@ jobs: build-ios: name: iOS + if: ${{ inputs.target_preset == 'all' || inputs.target_preset == 'mobile' || inputs.target_preset == 'ios' || inputs.target_preset == '' || (inputs.target_preset == 'custom' && inputs.build_ios) }} needs: prepare-deps timeout-minutes: 120 runs-on: macos-latest @@ -588,8 +640,9 @@ jobs: create-release: name: Create GitHub Release - if: ${{ !inputs.dry_run }} + if: ${{ !inputs.dry_run && !cancelled() && always() }} needs: + - prepare-deps - build-android - build-linux - build-linux-arm64 @@ -639,9 +692,23 @@ jobs: echo "tag=${TAG}" >> "$GITHUB_OUTPUT" - name: Download all artifacts + continue-on-error: true uses: actions/download-artifact@v8 + - name: Verify build artifacts exist + id: check_artifacts + run: | + COUNT=$(find . -maxdepth 2 -type f \( -name "*.apk" -o -name "*.deb" -o -name "*.rpm" -o -name "*.AppImage" -o -name "*.tar.xz" -o -name "*.exe" -o -name "*.dmg" -o -name "*.ipa" \) 2>/dev/null | wc -l) + echo "Found $COUNT build artifact(s)." + if [ "$COUNT" -eq 0 ]; then + echo "has_artifacts=false" >> "$GITHUB_OUTPUT" + echo "No build artifacts were found. Skipping release publish step." + else + echo "has_artifacts=true" >> "$GITHUB_OUTPUT" + fi + - name: Generate release notes + if: steps.check_artifacts.outputs.has_artifacts == 'true' run: | echo "## Downloads" > release-notes.md echo "" >> release-notes.md @@ -649,19 +716,20 @@ jobs: if [ -d "$dir" ]; then echo "### $dir" >> release-notes.md for f in "$dir"/*; do - echo "- \`$(basename "$f")\`" >> release-notes.md + [ -f "$f" ] && echo "- \`$(basename "$f")\`" >> release-notes.md done echo "" >> release-notes.md fi done - - name: Create Release + - name: Create or Update Release + if: steps.check_artifacts.outputs.has_artifacts == 'true' uses: softprops/action-gh-release@v3 with: tag_name: ${{ steps.vars.outputs.tag }} # A new tag (stable) would otherwise point at the default branch instead of the built commit target_commitish: ${{ github.sha }} - fail_on_unmatched_files: true + fail_on_unmatched_files: false name: ${{ steps.vars.outputs.release_name }} body_path: release-notes.md draft: ${{ steps.vars.outputs.is_draft }} From 182025dbc62238d12d62c2b36894720fc5fde58e Mon Sep 17 00:00:00 2001 From: pgwiz Date: Wed, 28 Oct 2026 11:55:33 +0300 Subject: [PATCH 14/16] ci: add artifact reuse workflow and fix release upload permissions Co-Authored-By: pgwiz --- .../workflows/publish-release-artifacts.yml | 67 +++++++++++++++++++ .github/workflows/spotube-release-binary.yml | 7 +- 2 files changed, 69 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/publish-release-artifacts.yml diff --git a/.github/workflows/publish-release-artifacts.yml b/.github/workflows/publish-release-artifacts.yml new file mode 100644 index 00000000..8b3567f5 --- /dev/null +++ b/.github/workflows/publish-release-artifacts.yml @@ -0,0 +1,67 @@ +name: Publish Existing Artifacts to Release + +on: + workflow_dispatch: + inputs: + run_id: + description: 'Workflow Run ID with built artifacts' + required: true + default: '40649093060' + type: string + release_tag: + description: 'Release tag (e.g. nightly or v6.0.0)' + required: true + default: 'nightly' + type: string + release_name: + description: 'Release name' + required: false + default: 'Spotube Multiplatform Build (nightly)' + type: string + prerelease: + description: 'Mark as pre-release' + required: false + default: true + type: boolean + +permissions: + contents: write + +jobs: + publish-artifacts: + name: Publish Artifacts to GitHub Release + runs-on: ubuntu-latest + steps: + - name: Download all artifacts from run + uses: actions/download-artifact@v4 + with: + run-id: ${{ inputs.run_id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + path: downloaded-artifacts + + - name: Inspect and organize artifacts + run: | + mkdir -p release-files + find downloaded-artifacts -type f \( -name "*.apk" -o -name "*.deb" -o -name "*.rpm" -o -name "*.AppImage" -o -name "*.tar.xz" -o -name "*.exe" -o -name "*.dmg" -o -name "*.ipa" \) -exec cp {} release-files/ \; + ls -lh release-files/ + + - name: Generate release notes + run: | + echo "## Downloads" > release-notes.md + echo "" >> release-notes.md + for f in release-files/*; do + if [ -f "$f" ]; then + echo "- \`$(basename "$f")\`" >> release-notes.md + fi + done + + - name: Upload to GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ inputs.release_tag }} + name: ${{ inputs.release_name }} + body_path: release-notes.md + prerelease: ${{ inputs.prerelease }} + draft: false + fail_on_unmatched_files: false + files: release-files/* diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index 9aa39933..b64aa8d4 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -69,9 +69,8 @@ on: type: boolean default: false -# Read-only by default; only create-release gets write access permissions: - contents: read + contents: write # Never cancel a running release when another is dispatched, and let stable, beta and nightly run side by side concurrency: @@ -724,11 +723,9 @@ jobs: - name: Create or Update Release if: steps.check_artifacts.outputs.has_artifacts == 'true' - uses: softprops/action-gh-release@v3 + uses: softprops/action-gh-release@v2 with: tag_name: ${{ steps.vars.outputs.tag }} - # A new tag (stable) would otherwise point at the default branch instead of the built commit - target_commitish: ${{ github.sha }} fail_on_unmatched_files: false name: ${{ steps.vars.outputs.release_name }} body_path: release-notes.md From b4f25f0e27aa006bc6324fa4f73488494d524b41 Mon Sep 17 00:00:00 2001 From: pgwiz Date: Fri, 30 Oct 2026 02:10:32 +0300 Subject: [PATCH 15/16] fix(player): log ExoPlayer playback errors and connect errorFlow in DeviceAudioPlayerQueue Co-Authored-By: pgwiz --- .../spotube/spotube/core/audioplayer/AudioPlayer.android.kt | 1 + .../spotube/core/audioplayer/DeviceAudioPlayerQueue.kt | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.android.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.android.kt index 2bda1426..8888103f 100644 --- a/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.android.kt +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.android.kt @@ -127,6 +127,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface } override fun onPlayerError(error: PlaybackException) { + Log.e("AudioPlayer", "ExoPlayer error: ${error.errorCodeName} (${error.errorCode}): ${error.message}", error) _error.tryEmit(error) } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt index 4c987325..33f52350 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt @@ -103,6 +103,12 @@ class DeviceAudioPlayerQueue( } } + scope.launch { + audioPlayer.errorFlow.collect { error -> + logger.e(error) { "AudioPlayer error encountered in queue: ${error.message}" } + } + } + scope.launch { audioPlayer.currentMediaItemFlow.collect { logger.d { "Current media item changed, persisting queue state" } From cd6780684536ee4e0da3ddda33aa740cebf7caab Mon Sep 17 00:00:00 2001 From: pgwiz Date: Fri, 30 Oct 2026 03:50:58 +0300 Subject: [PATCH 16/16] feat(diagnostics): add in-app logs viewer and fix proxy port mismatch/startup race Co-Authored-By: pgwiz --- .../core/audioplayer/AudioPlayer.android.kt | 3 + .../spotube/media/MediaBrowseHelper.kt | 13 +- .../composeResources/values/strings.xml | 11 + .../audioplayer/DeviceAudioPlayerQueue.kt | 12 +- .../org/spotube/spotube/core/di/Init.kt | 2 + .../spotube/core/logger/AppLogBuffer.kt | 95 ++++ .../core/navigation/NavigationModule.kt | 7 + .../core/navigation/NavigationState.kt | 1 + .../spotube/core/server/LocalServer.kt | 18 + .../modules/settings/SettingsScreen.kt | 3 + .../modules/settings/SettingsSidebarLayout.kt | 7 + .../modules/settings/logs/LogsScreen.kt | 460 ++++++++++++++++++ .../modules/settings/sections/LogsSection.kt | 56 +++ 13 files changed, 681 insertions(+), 7 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/org/spotube/spotube/core/logger/AppLogBuffer.kt create mode 100644 composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/logs/LogsScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/LogsSection.kt diff --git a/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.android.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.android.kt index 8888103f..c7217660 100644 --- a/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.android.kt +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.android.kt @@ -20,6 +20,7 @@ package org.spotube.spotube.core.audioplayer import android.content.Context import android.content.Intent import android.util.Log +import co.touchlab.kermit.Logger import androidx.media3.common.AudioAttributes import androidx.media3.common.C import androidx.media3.common.MediaMetadata @@ -49,6 +50,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface actual val context: Any = context private val appContext: Context = (context as Context).applicationContext + private val kermitLogger = Logger.withTag("AudioPlayer") private fun ensureServiceStarted() { // A plain start, never startForegroundService: that obliges the service to call @@ -127,6 +129,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface } override fun onPlayerError(error: PlaybackException) { + kermitLogger.e(error) { "ExoPlayer error: ${error.errorCodeName} (${error.errorCode}): ${error.message}" } Log.e("AudioPlayer", "ExoPlayer error: ${error.errorCodeName} (${error.errorCode}): ${error.message}", error) _error.tryEmit(error) } diff --git a/composeApp/src/androidMain/kotlin/org/spotube/spotube/media/MediaBrowseHelper.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/media/MediaBrowseHelper.kt index c3ba32df..a1d0a0ac 100644 --- a/composeApp/src/androidMain/kotlin/org/spotube/spotube/media/MediaBrowseHelper.kt +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/media/MediaBrowseHelper.kt @@ -29,6 +29,8 @@ import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlayl import org.spotube.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchResult import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.server.LocalServer +import org.koin.core.component.KoinComponent import org.spotube.spotube.core.audioplayer.QueueEntry import org.spotube.spotube.core.playback.CollectionPlaybackHelper import org.spotube.spotube.modules.album.AlbumRepository @@ -662,9 +664,12 @@ class MediaBrowseHelper( } private suspend fun buildStreamingUrl(trackId: String): String? { - val settings = settingsRepository.userSettings.first() - val port = settings.playbackProxyServerPort - if (port == 0) return null - return "http://127.0.0.1:$port/stream/$trackId" + val baseUrl = localServer?.awaitBaseUrl() ?: run { + val settings = settingsRepository.userSettings.first() + val port = settings.playbackProxyServerPort + if (port == 0) return null + "http://127.0.0.1:$port" + } + return "${baseUrl.trimEnd('/')}/stream/$trackId" } } diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index a5481e2e..00471e06 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -112,6 +112,17 @@ Updates Support About + Logs and Diagnostics + Logs and Diagnostics + Inspect player, proxy server, and error logs + Copy Logs + Clear Logs + Copied to clipboard! + No logs captured yet. + All + Errors + Warnings + Filter by tag or keyword… Close Save diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt index 33f52350..7f65d64c 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt @@ -24,6 +24,7 @@ import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import org.spotube.spotube.core.di.injectLogger import org.spotube.spotube.core.newpipe.NewPipeService import org.spotube.spotube.core.newpipe.SearchKind +import org.spotube.spotube.core.server.LocalServer import org.spotube.spotube.modules.blacklist.BlacklistRepository import org.spotube.spotube.modules.plugin.PluginProvider import org.spotube.spotube.modules.settings.SettingsProvider @@ -620,10 +621,15 @@ class DeviceAudioPlayerQueue( url = url) } + private val localServerOrNull: LocalServer? + get() = runCatching { getKoin().getOrNull() }.getOrNull() + private suspend fun buildStreamingUrl(trackId: String, protocol: StreamProtocol): String { - val port: Int = - settingsProvider.settingsState.mapNotNull { it?.playbackProxyServerPort }.first() - val baseUrl = "http://127.0.0.1:$port" + val baseUrl = localServerOrNull?.awaitBaseUrl() ?: run { + val port: Int = + settingsProvider.settingsState.mapNotNull { it?.playbackProxyServerPort }.first() + "http://127.0.0.1:$port" + } return when (protocol) { StreamProtocol.HLS, StreamProtocol.DASH -> "${baseUrl.trimEnd('/')}/manifest/$trackId" StreamProtocol.PROGRESSIVE -> "${baseUrl.trimEnd('/')}/stream/$trackId" diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Init.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Init.kt index b0491bc4..343d161c 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Init.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Init.kt @@ -21,8 +21,10 @@ import co.touchlab.kermit.Logger import co.touchlab.kermit.koin.KermitKoinLogger import org.koin.core.context.startKoin import org.koin.dsl.KoinAppDeclaration +import org.spotube.spotube.core.logger.AppLogWriter fun initKoin(configuration: KoinAppDeclaration? = null) { + Logger.addLogWriter(AppLogWriter()) startKoin { configuration?.invoke(this) logger( diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/logger/AppLogBuffer.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/logger/AppLogBuffer.kt new file mode 100644 index 00000000..c7198753 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/logger/AppLogBuffer.kt @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.logger + +import co.touchlab.kermit.LogWriter +import co.touchlab.kermit.Severity +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime + +data class LogEntry( + val id: Long, + val timestamp: Instant = Clock.System.now(), + val severity: Severity, + val tag: String, + val message: String, + val throwable: Throwable? = null +) { + val formattedTime: String + get() { + val dt = timestamp.toLocalDateTime(TimeZone.currentSystemDefault()) + val h = dt.hour.toString().padStart(2, '0') + val m = dt.minute.toString().padStart(2, '0') + val s = dt.second.toString().padStart(2, '0') + val ms = (dt.nanosecond / 1_000_000).toString().padStart(3, '0') + return "$h:$m:$s.$ms" + } +} + +object AppLogBuffer { + private const val MAX_LOGS = 1000 + private var counter = 0L + + private val _logs = MutableStateFlow>(emptyList()) + val logs = _logs.asStateFlow() + + fun append(severity: Severity, tag: String, message: String, throwable: Throwable? = null) { + val entry = LogEntry( + id = ++counter, + severity = severity, + tag = tag, + message = message, + throwable = throwable + ) + _logs.update { list -> + if (list.size >= MAX_LOGS) { + list.drop(list.size - MAX_LOGS + 1) + entry + } else { + list + entry + } + } + } + + fun clear() { + _logs.value = emptyList() + } + + fun formatAllLogs(): String { + return _logs.value.joinToString("\n") { entry -> + val header = "[${entry.formattedTime}] [${entry.severity.name}] [${entry.tag}] ${entry.message}" + val err = entry.throwable?.stackTraceToString()?.let { "\n$it" }.orEmpty() + header + err + } + } +} + +class AppLogWriter : LogWriter() { + override fun log(severity: Severity, message: String, tag: String, throwable: Throwable?) { + AppLogBuffer.append( + severity = severity, + tag = tag, + message = message, + throwable = throwable + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationModule.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationModule.kt index 0410a281..3f9eeb4f 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationModule.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationModule.kt @@ -36,6 +36,7 @@ import org.spotube.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID import org.spotube.spotube.modules.saved_tracks.SavedTracksScreen import org.spotube.spotube.modules.search.SearchScreen import org.spotube.spotube.modules.settings.SettingsScreen +import org.spotube.spotube.modules.settings.logs.LogsScreen import org.spotube.spotube.modules.settings.JamSettingsScreen import org.spotube.spotube.modules.welcome.WelcomeScreen import kotlinx.serialization.Serializable @@ -101,6 +102,9 @@ sealed interface Routes : NavKey { @Serializable data class LocalFolder(val folderPath: String) : Routes + + @Serializable + data object Logs : Routes } @OptIn(KoinExperimentalAPI::class) @@ -208,4 +212,7 @@ val navigationModule = module { navigation { WelcomeScreen(viewModel = koinViewModel()) } + navigation { + LogsScreen() + } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationState.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationState.kt index 3dc7461e..89b579ab 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationState.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationState.kt @@ -65,6 +65,7 @@ val serializersConfig = SavedStateConfiguration { subclass(Routes.Jam::class, Routes.Jam.serializer()) subclass(Routes.Welcome::class, Routes.Welcome.serializer()) subclass(Routes.LocalFolder::class, Routes.LocalFolder.serializer()) + subclass(Routes.Logs::class, Routes.Logs.serializer()) } } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/LocalServer.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/LocalServer.kt index 14e4837c..b90affa0 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/LocalServer.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/LocalServer.kt @@ -48,6 +48,11 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex @@ -88,6 +93,19 @@ class LocalServer( port?.let { "http://$HOST_LOCAL:$it" } }.stateIn(scope, SharingStarted.WhileSubscribed(5_000), null) + suspend fun awaitPort(timeout: Duration = 3.seconds): Int { + val current = activePort.value + if (current != null) return current + return withTimeoutOrNull(timeout) { + activePort.filterNotNull().first() + } ?: settingsViewModel.settingsState.value?.playbackProxyServerPort ?: 14769 + } + + suspend fun awaitBaseUrl(timeout: Duration = 3.seconds): String { + val port = awaitPort(timeout) + return "http://$HOST_LOCAL:$port" + } + private val cachedCacheEnabled = MutableStateFlow(false) private val streamProxy by lazy { diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsScreen.kt index e79ebe0b..cf8d4453 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsScreen.kt @@ -51,6 +51,7 @@ import org.spotube.spotube.modules.settings.sections.languageRegionSection import org.spotube.spotube.modules.settings.sections.playbackSection import org.spotube.spotube.modules.settings.sections.pluginsSection import org.spotube.spotube.modules.settings.sections.supportSection +import org.spotube.spotube.modules.settings.sections.logsSection import org.spotube.spotube.modules.settings.sections.updatesSection import org.spotube.spotube.modules.shell.LocalAppShellBottomInset import org.jetbrains.compose.resources.stringResource @@ -161,6 +162,8 @@ private fun CompactSettingsContent( updatesSection( settings = settings, settingsViewModel = settingsViewModel) + logsSection( + navigatorCommands = navigatorCommands) } } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsSidebarLayout.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsSidebarLayout.kt index 3ff64bac..1c95f1e3 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsSidebarLayout.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsSidebarLayout.kt @@ -97,6 +97,8 @@ import spotube.composeapp.generated.resources.common_close import spotube.composeapp.generated.resources.settings_blacklist_title import spotube.composeapp.generated.resources.settings_screen_title import spotube.composeapp.generated.resources.settings_section_about +import spotube.composeapp.generated.resources.settings_section_logs +import org.spotube.spotube.modules.settings.logs.LogsContent import spotube.composeapp.generated.resources.settings_section_appearance import spotube.composeapp.generated.resources.settings_section_caching import spotube.composeapp.generated.resources.settings_section_desktop @@ -129,6 +131,7 @@ internal enum class SettingsTab( Desktop(Res.string.settings_section_desktop, Iconsax.IconsaxImportArrow2), Support(Res.string.settings_section_support, Iconsax.IconsaxHeart2), About(Res.string.settings_section_about, Iconsax.IconsaxInformation), + Logs(Res.string.settings_section_logs, Iconsax.IconsaxDocumentText), } /** @@ -283,6 +286,10 @@ private fun SettingsTabContent( JamSettingsContent(viewModel = koinViewModel()) } + SettingsTab.Logs -> { + LogsContent(modifier = Modifier.fillMaxSize()) + } + else -> { if (settings == null) { Box( diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/logs/LogsScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/logs/LogsScreen.kt new file mode 100644 index 00000000..23681b0b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/logs/LogsScreen.kt @@ -0,0 +1,460 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.settings.logs + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import co.touchlab.kermit.Severity +import com.composeunstyled.Text +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.koinInject +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumTextField +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.logger.AppLogBuffer +import org.spotube.spotube.core.logger.LogEntry +import org.spotube.spotube.core.server.LocalServer +import org.spotube.spotube.core.ui.component.ApplicationMainBar +import org.spotube.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.modules.plugin.PluginManager +import org.spotube.spotube.modules.shell.LocalAppShellBottomInset +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxDocumentText +import org.spotube.spotube.resources.iconsax.IconsaxFilterSearch +import org.spotube.spotube.resources.iconsax.IconsaxShare +import org.spotube.spotube.resources.iconsax.IconsaxTrash +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.settings_logs_clear +import spotube.composeapp.generated.resources.settings_logs_copied +import spotube.composeapp.generated.resources.settings_logs_copy +import spotube.composeapp.generated.resources.settings_logs_empty +import spotube.composeapp.generated.resources.settings_logs_filter_all +import spotube.composeapp.generated.resources.settings_logs_filter_errors +import spotube.composeapp.generated.resources.settings_logs_filter_warnings +import spotube.composeapp.generated.resources.settings_logs_search_hint +import spotube.composeapp.generated.resources.settings_logs_title + +private enum class LogFilter { + ALL, + ERRORS, + WARNINGS +} + +@Composable +fun LogsScreen() { + Column(modifier = Modifier.fillMaxSize()) { + ApplicationMainBar( + backButton = true, + title = { Text(stringResource(Res.string.settings_logs_title)) } + ) + LogsContent(modifier = Modifier.weight(1f)) + } +} + +@Composable +internal fun LogsContent(modifier: Modifier = Modifier) { + val logs by AppLogBuffer.logs.collectAsStateWithLifecycle() + val shellBottomInset = LocalAppShellBottomInset.current + val clipboardManager = LocalClipboardManager.current + val coroutineScope = rememberCoroutineScope() + + val localServer = runCatching { koinInject() }.getOrNull() + val localServerPort by (localServer?.port?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(null) }) + val pluginManager = runCatching { koinInject() }.getOrNull() + val audioPlugin by (pluginManager?.selectedAudioPlugin?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(null) }) + val audioPlayerQueue = runCatching { koinInject() }.getOrNull() + val currentQueueEntry by (audioPlayerQueue?.currentQueueEntryFlow?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(null) }) + + val searchState = rememberTextFieldState("") + val searchQuery = searchState.text.toString().trim().lowercase() + var selectedFilter by remember { mutableStateOf(LogFilter.ALL) } + var copiedFeedback by remember { mutableStateOf(false) } + + val filteredLogs = remember(logs, searchQuery, selectedFilter) { + logs.filter { entry -> + val matchesSeverity = when (selectedFilter) { + LogFilter.ALL -> true + LogFilter.ERRORS -> entry.severity == Severity.Error || entry.severity == Severity.Assert + LogFilter.WARNINGS -> entry.severity == Severity.Warn || entry.severity == Severity.Error || entry.severity == Severity.Assert + } + val matchesSearch = if (searchQuery.isBlank()) { + true + } else { + entry.tag.lowercase().contains(searchQuery) || entry.message.lowercase().contains(searchQuery) + } + matchesSeverity && matchesSearch + }.asReversed() + } + + val listState = rememberLazyListState() + + Column(modifier = modifier.fillMaxSize().padding(horizontal = 16.dp)) { + // Diagnostic status overview card + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + .clip(RoundedCornerShape(12.dp)) + .background(AppTheme.colors.surfaceVariant.copy(alpha = 0.5f)) + .padding(12.dp) + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = "System Diagnostics", + style = AppTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = AppTheme.colors.primary + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "Proxy Server:", + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.textMuted + ) + Text( + text = if (localServerPort != null) "127.0.0.1:$localServerPort (Active)" else "Starting...", + style = AppTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + color = if (localServerPort != null) AppTheme.colors.primary else AppTheme.colors.textMuted + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "Audio Plugin:", + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.textMuted + ) + Text( + text = audioPlugin?.name ?: "NewPipe YouTube (Built-in)", + style = AppTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + color = AppTheme.colors.onSurface + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "Current Track:", + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.textMuted + ) + Text( + text = currentQueueEntry?.let { entry -> + when (entry) { + is org.spotube.spotube.core.audioplayer.QueueEntry.StreamingTrack -> entry.track.title + is org.spotube.spotube.core.audioplayer.QueueEntry.LocalTrack -> entry.name + } + } ?: "None", + style = AppTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + color = AppTheme.colors.onSurface + ) + } + } + } + + // Action & Filter row + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + MillenniumTextField( + state = searchState, + placeholder = { Text(stringResource(Res.string.settings_logs_search_hint), fontSize = 12.sp) }, + leadingIcon = { + MillenniumIcon( + imageVector = Iconsax.IconsaxFilterSearch, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + }, + modifier = Modifier.weight(1f) + ) + + MillenniumButton( + onClick = { + val fullLogs = AppLogBuffer.formatAllLogs() + clipboardManager.setText(AnnotatedString(fullLogs)) + copiedFeedback = true + coroutineScope.launch { + delay(2000) + copiedFeedback = false + } + }, + style = MillenniumButtonStyle.Secondary + ) { + MillenniumIcon( + imageVector = Iconsax.IconsaxShare, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = if (copiedFeedback) stringResource(Res.string.settings_logs_copied) else stringResource(Res.string.settings_logs_copy), + style = AppTheme.typography.labelSmall + ) + } + + MillenniumIconButton( + onClick = { AppLogBuffer.clear() }, + style = MillenniumIconButtonStyle.Secondary, + contentDescription = stringResource(Res.string.settings_logs_clear) + ) { + MillenniumIcon( + imageVector = Iconsax.IconsaxTrash, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + } + } + + // Filter chips row + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + LogFilterChip( + label = stringResource(Res.string.settings_logs_filter_all), + selected = selectedFilter == LogFilter.ALL, + count = logs.size, + onClick = { selectedFilter = LogFilter.ALL } + ) + LogFilterChip( + label = stringResource(Res.string.settings_logs_filter_errors), + selected = selectedFilter == LogFilter.ERRORS, + count = logs.count { it.severity == Severity.Error || it.severity == Severity.Assert }, + chipColor = Color(0xFFE53935), + onClick = { selectedFilter = LogFilter.ERRORS } + ) + LogFilterChip( + label = stringResource(Res.string.settings_logs_filter_warnings), + selected = selectedFilter == LogFilter.WARNINGS, + count = logs.count { it.severity == Severity.Warn }, + chipColor = Color(0xFFFB8C00), + onClick = { selectedFilter = LogFilter.WARNINGS } + ) + } + + // Logs list + if (filteredLogs.isEmpty()) { + Box( + modifier = Modifier.weight(1f).fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + MillenniumIcon( + imageVector = Iconsax.IconsaxDocumentText, + contentDescription = null, + tint = AppTheme.colors.textMuted, + modifier = Modifier.size(36.dp) + ) + Text( + text = stringResource(Res.string.settings_logs_empty), + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.textMuted + ) + } + } + } else { + LazyColumn( + state = listState, + modifier = Modifier.weight(1f).fillMaxWidth(), + contentPadding = PaddingValues(top = 4.dp, bottom = 16.dp + shellBottomInset), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + items(filteredLogs, key = { it.id }) { logEntry -> + LogEntryCard(entry = logEntry) + } + } + } + } +} + +@Composable +private fun LogFilterChip( + label: String, + selected: Boolean, + count: Int, + chipColor: Color = AppTheme.colors.primary, + onClick: () -> Unit +) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background( + if (selected) chipColor.copy(alpha = 0.2f) else AppTheme.colors.surfaceVariant.copy(alpha = 0.3f) + ) + .clickable(onClick = onClick) + .padding(horizontal = 10.dp, vertical = 6.dp) + ) { + Text( + text = "$label ($count)", + style = AppTheme.typography.labelSmall, + fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal, + color = if (selected) chipColor else AppTheme.colors.onSurface + ) + } +} + +@Composable +private fun LogEntryCard(entry: LogEntry) { + var expanded by remember { mutableStateOf(false) } + + val severityColor = when (entry.severity) { + Severity.Error, Severity.Assert -> Color(0xFFE53935) + Severity.Warn -> Color(0xFFFB8C00) + Severity.Info -> AppTheme.colors.primary + Severity.Debug -> AppTheme.colors.textMuted + Severity.Verbose -> AppTheme.colors.textMuted.copy(alpha = 0.7f) + } + + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(AppTheme.colors.surfaceVariant.copy(alpha = 0.35f)) + .clickable(enabled = entry.throwable != null) { expanded = !expanded } + .padding(8.dp) + ) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + // Severity label + Box( + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(severityColor.copy(alpha = 0.18f)) + .padding(horizontal = 4.dp, vertical = 1.dp) + ) { + Text( + text = entry.severity.name.take(4), + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + color = severityColor + ) + } + + // Timestamp + Text( + text = entry.formattedTime, + fontSize = 11.sp, + fontFamily = FontFamily.Monospace, + color = AppTheme.colors.textMuted + ) + + // Tag + Text( + text = "[${entry.tag}]", + fontSize = 11.sp, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.SemiBold, + color = AppTheme.colors.onSurface + ) + } + + // Message + Text( + text = entry.message, + fontSize = 12.sp, + fontFamily = FontFamily.Monospace, + color = AppTheme.colors.onSurface, + lineHeight = 16.sp + ) + + // Throwable toggle / stack trace + if (entry.throwable != null) { + Text( + text = if (expanded) "▲ Hide stack trace" else "▼ Show stack trace (${entry.throwable::class.simpleName})", + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + color = severityColor + ) + AnimatedVisibility(visible = expanded) { + Text( + text = entry.throwable.stackTraceToString(), + fontSize = 10.sp, + fontFamily = FontFamily.Monospace, + color = AppTheme.colors.textMuted, + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp) + .clip(RoundedCornerShape(4.dp)) + .background(Color.Black.copy(alpha = 0.3f)) + .padding(6.dp) + ) + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/LogsSection.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/LogsSection.kt new file mode 100644 index 00000000..dc124e61 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/LogsSection.kt @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.settings.sections + +import androidx.compose.foundation.lazy.LazyListScope +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.modules.settings.components.SettingCardItem +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxDocumentText +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.settings_section_logs +import spotube.composeapp.generated.resources.settings_logs_title +import spotube.composeapp.generated.resources.settings_logs_subtitle + +internal fun LazyListScope.logsSection( + navigatorCommands: NavigationCommands, + includeHeader: Boolean = true +) { + if (includeHeader) { + settingsSectionHeader(Res.string.settings_section_logs) + } + settingsSectionCard( + items = listOf { + SettingCardItem( + title = stringResource(Res.string.settings_logs_title), + subtitle = stringResource(Res.string.settings_logs_subtitle), + icon = { + SettingsItemIcon( + imageVector = Iconsax.IconsaxDocumentText, + contentDescription = stringResource(Res.string.settings_logs_title) + ) + }, + onClick = { + navigatorCommands.navigateTo(Routes.Logs) + } + ) + } + ) +}