fix(player): harden StreamProxy Origin header, YouTube URL regex boundaries, and candidate fallback order

Co-Authored-By: pgwiz <pgwiz@users.noreply.github.com>
This commit is contained in:
pgwiz 2026-10-28 01:32:02 +03:00
parent e4fe197184
commit f622b4b155
5 changed files with 23 additions and 16 deletions

View File

@ -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)

View File

@ -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()

View File

@ -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
}
}

View File

@ -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 {

View File

@ -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