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