fix(player): resolve GoogleVideo 403, YouTube video ID extraction, matching relaxation, and radio mix fallback

Co-Authored-By: pgwiz <pgwiz@users.noreply.github.com>
This commit is contained in:
pgwiz 2026-10-28 00:34:20 +03:00
parent a573570de9
commit e4fe197184
8 changed files with 264 additions and 48 deletions

View File

@ -1,5 +1,14 @@
# Changelog # 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) ## [6.0.0-beta.0](https://github.com/team-spotube/spotube/compare/v5.1.2...v6.0.0-beta.0) (2026-10-26)
### Features ### Features

View File

@ -155,6 +155,47 @@ actual class NewPipeService {
) )
} }
} }
actual suspend fun getRadioMix(videoId: String): List<VideoSearchResult> {
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()
}
}
} }
/** /**

View File

@ -18,7 +18,12 @@
package org.spotube.spotube.core.audioplayer package org.spotube.spotube.core.audioplayer
import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol 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.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.blacklist.BlacklistRepository
import org.spotube.spotube.modules.plugin.PluginProvider import org.spotube.spotube.modules.plugin.PluginProvider
import org.spotube.spotube.modules.settings.SettingsProvider import org.spotube.spotube.modules.settings.SettingsProvider
@ -330,19 +335,74 @@ class DeviceAudioPlayerQueue(
isFetchingRecommendations = true isFetchingRecommendations = true
logger.i { "Endless playback: fetching recommendations with ${seedTrackIds.size} seed tracks" } logger.i { "Endless playback: fetching recommendations with ${seedTrackIds.size} seed tracks" }
val metadataService = pluginProvider.selectedMetadataPlugin.value ?: run { val metadataService = pluginProvider.selectedMetadataPlugin.value
logger.w { "Endless playback: no metadata plugin available" } var recommendations: List<MetadataTrack> = emptyList()
isFetchingRecommendations = false
return
}
try { if (metadataService != null) {
val recommendations = metadataService.use { recommendations = runCatching {
metadataService.use {
metadataTrackAPI.recommendationsBasedOnTracks(seedTrackIds, limit = 20) metadataTrackAPI.recommendationsBasedOnTracks(seedTrackIds, limit = 20)
} }
}.getOrDefault(emptyList())
}
if (recommendations.isEmpty()) { if (recommendations.isEmpty()) {
logger.w { "Endless playback: no recommendations returned" } 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 {
if (recommendations.isEmpty()) {
logger.w { "Endless playback: no recommendations or radio mix tracks found" }
isFetchingRecommendations = false isFetchingRecommendations = false
return return
} }
@ -369,7 +429,7 @@ class DeviceAudioPlayerQueue(
logger.i { "Endless playback: adding ${newEntries.size} recommended tracks to queue" } logger.i { "Endless playback: adding ${newEntries.size} recommended tracks to queue" }
addAllToQueue(newEntries) addAllToQueue(newEntries)
} catch (e: Exception) { } catch (e: Exception) {
logger.e(e) { "Endless playback: failed to fetch recommendations" } logger.e(e) { "Endless playback: failed to add recommendations to queue" }
} finally { } finally {
isFetchingRecommendations = false isFetchingRecommendations = false
} }

View File

@ -28,4 +28,5 @@ enum class SearchKind {
expect class NewPipeService() { expect class NewPipeService() {
suspend fun searchVideos(query: String, kind: SearchKind = SearchKind.Songs): List<VideoSearchResult> suspend fun searchVideos(query: String, kind: SearchKind = SearchKind.Songs): List<VideoSearchResult>
suspend fun getVideoInfo(id: String): VideoInfo suspend fun getVideoInfo(id: String): VideoInfo
suspend fun getRadioMix(videoId: String): List<VideoSearchResult>
} }

View File

@ -34,6 +34,7 @@ import io.ktor.http.HeadersBuilder
import io.ktor.http.HttpHeaders import io.ktor.http.HttpHeaders
import io.ktor.http.HttpMethod import io.ktor.http.HttpMethod
import io.ktor.http.HttpStatusCode import io.ktor.http.HttpStatusCode
import io.ktor.http.Url
import io.ktor.http.content.OutgoingContent import io.ktor.http.content.OutgoingContent
import io.ktor.http.decodeURLQueryComponent import io.ktor.http.decodeURLQueryComponent
import io.ktor.http.encodeURLParameter import io.ktor.http.encodeURLParameter
@ -72,6 +73,9 @@ internal class StreamProxy(
companion object { companion object {
private const val HOST = "127.0.0.1" 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 private val fileSystem = FileSystem.SYSTEM
@ -233,8 +237,7 @@ internal class StreamProxy(
val response = runCatching { val response = runCatching {
httpClient.request(manifestUrl) { httpClient.request(manifestUrl) {
method = HttpMethod.Get method = HttpMethod.Get
call.forwardRequestHeaderIfPresent(headers, HttpHeaders.Accept) applyUpstreamHeaders(call, this, manifestUrl, includeRange = false)
call.forwardRequestHeaderIfPresent(headers, HttpHeaders.UserAgent)
} }
}.getOrElse { throwable -> }.getOrElse { throwable ->
logger.w(throwable) { "Failed to fetch manifest for track $trackId" } logger.w(throwable) { "Failed to fetch manifest for track $trackId" }
@ -284,9 +287,7 @@ internal class StreamProxy(
runCatching { runCatching {
httpClient.prepareRequest(segmentUrl) { httpClient.prepareRequest(segmentUrl) {
method = HttpMethod.Get method = HttpMethod.Get
call.forwardRequestHeaderIfPresent(headers, HttpHeaders.Range) applyUpstreamHeaders(call, this, segmentUrl, includeRange = true)
call.forwardRequestHeaderIfPresent(headers, HttpHeaders.Accept)
call.forwardRequestHeaderIfPresent(headers, HttpHeaders.UserAgent)
}.execute { response -> respondWithSegment(call, response, trackId) } }.execute { response -> respondWithSegment(call, response, trackId) }
}.onFailure { throwable -> }.onFailure { throwable ->
if (throwable is CancellationException) throw throwable if (throwable is CancellationException) throw throwable
@ -343,10 +344,7 @@ internal class StreamProxy(
val failure = runCatching { val failure = runCatching {
httpClient.prepareRequest(currentUrl) { httpClient.prepareRequest(currentUrl) {
method = requestMethod method = requestMethod
call.forwardRequestHeaderIfPresent(headers, HttpHeaders.Range) applyUpstreamHeaders(call, this, currentUrl, includeRange = true)
call.forwardRequestHeaderIfPresent(headers, HttpHeaders.IfRange)
call.forwardRequestHeaderIfPresent(headers, HttpHeaders.Accept)
call.forwardRequestHeaderIfPresent(headers, HttpHeaders.UserAgent)
}.execute { response -> }.execute { response ->
logger.i { logger.i {
"[Req] upstream headers for $trackId after ${upstreamStart.elapsedNow()}: " + "[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 encodeUrl(url: String): String = url.encodeURLParameter()
private fun decodeUrl(encoded: String): String = encoded.decodeURLQueryComponent() 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( private fun ApplicationCall.forwardRequestHeaderIfPresent(
builder: HeadersBuilder, builder: HeadersBuilder,
headerName: String headerName: String
@ -590,8 +617,12 @@ internal class StreamProxy(
private fun upstreamHeadersToForward(upstream: HttpResponse): Headers { private fun upstreamHeadersToForward(upstream: HttpResponse): Headers {
return HeadersBuilder().apply { return HeadersBuilder().apply {
upstream.headers.forEach { name, values -> upstream.headers.forEach { name, values ->
if (!name.equals(HttpHeaders.TransferEncoding, ignoreCase = true) &&
!name.equals(HttpHeaders.Connection, ignoreCase = true)
) {
values.forEach { value -> append(name, value) } values.forEach { value -> append(name, value) }
} }
}
}.build() }.build()
} }
} }

View File

@ -55,29 +55,47 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent {
var confidence = 0f var confidence = 0f
val videoTitle = normalize(video.title) val videoTitle = normalize(video.title)
val isTopicChannel = video.uploader.trim().endsWith("- Topic", ignoreCase = true) val isTopicChannel = video.uploader.trim().endsWith("- Topic", ignoreCase = true) ||
val uploader = normalize(video.uploader.replace(Regex("(?i)\\s*-\\s*topic$"), "")) 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 artistNames = track.artists.map { normalize(it.name) }.filter { it.isNotEmpty() }
val title = coreTitle(track.title) val title = coreTitle(track.title)
val videoTokens = videoTitle.split(' ').toSet() val videoTokens = videoTitle.split(' ').toSet()
val isTitleMatch = title.isNotEmpty() && 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 isArtistInTitle = artistNames.any { it in videoTitle }
val isAlbumInTitle = track.album?.title?.let { normalize(it) } val isAlbumInTitle = track.album?.title?.let { normalize(it) }
?.takeIf { it.isNotEmpty() }?.let { it in videoTitle } ?: false ?.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 // Normalize whitespace and punctuation for uploader comparison (e.g. "Taylor Swift" vs "TaylorSwiftVEVO")
val durationDiff = if (video.durationMs > 0) kotlin.math.abs(video.durationMs - track.durationMs) else Long.MAX_VALUE 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 (isTitleMatch) confidence += 0.3f
if (isUploaderMatch) 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 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 (isArtistInTitle) confidence += 0.1f
if (isAlbumInTitle) 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) confidence = confidence.coerceAtMost(1f)
logger.i { 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<AudioSource> { override suspend fun getStreamsByTrack(track: MetadataTrack): List<AudioSource> {
val isYouTubeUrl = val resolvedVideoId = if (track.id.matches(youtubeIDRegex)) {
track.externalUri != null && (track.externalUri!!.contains("youtube.com") || track.externalUri!!.contains( track.id
"youtu.be" } else {
)) extractYouTubeVideoId(track.externalUri)
val isYouTubeID = track.id.matches(youtubeIDRegex) }
logger.i { logger.i {
"Searching streams for track(${track.id}): ${track.title} by ${ "Searching streams for track(${track.id}): ${track.title} by ${
@ -136,9 +171,9 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent {
) { it.name } ) { it.name }
}" }"
} }
if (isYouTubeID || isYouTubeUrl) { if (resolvedVideoId != null) {
logger.i { "Track has YouTube ID or URL, fetching video info directly" } logger.i { "Track has YouTube ID or URL ($resolvedVideoId), fetching video info directly" }
return newPipeService.getVideoInfo(track.id).let { videoInfo -> return newPipeService.getVideoInfo(resolvedVideoId).let { videoInfo ->
listOf( listOf(
AudioSource.Streamed( AudioSource.Streamed(
id = videoInfo.id, id = videoInfo.id,
@ -228,19 +263,33 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent {
if (matched.isNotEmpty()) return matched.take(5) if (matched.isNotEmpty()) return matched.take(5)
// Nothing scored well. Playing YouTube's best text-search guess beats failing playback, // When confidence scoring yields no candidates >= 0.3, ensure the top relevance search
// and ISRC results are skipped because an ISRC text search tends to return unrelated songs. // result is returned as fallback rather than throwing TrackNotFoundError or failing.
val fallback = textSearchResults.values.toList() val fallback = if (textSearchResults.isNotEmpty()) {
textSearchResults.values.toList()
.sortedByDescending { it.confidence } // stable: ties keep YouTube's relevance order .sortedByDescending { it.confidence } // stable: ties keep YouTube's relevance order
.take(5) .take(5)
.ifEmpty { ranked.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)
}
if (fallback.isNotEmpty()) {
logger.w { logger.w {
"[Search] no confident match for '${track.title}'; falling back to best guesses: " + "[Search] no confident match for '${track.title}'; falling back to top relevance result: " +
fallback.joinToString { "${it.id} conf=${it.confidence} '${it.title}'" } fallback.joinToString { "${it.id} conf=${it.confidence} '${it.title}'" }
} }
return fallback return fallback
} }
logger.w { "[Search] no search results found at all for '${track.title}'" }
return emptyList()
}
override suspend fun getStreamsOfAudioSource(source: AudioSource.Basic): List<AudioSource.Streamed> { override suspend fun getStreamsOfAudioSource(source: AudioSource.Basic): List<AudioSource.Streamed> {
return newPipeService.getVideoInfo(source.id).let { videoInfo -> return newPipeService.getVideoInfo(source.id).let { videoInfo ->
listOf( listOf(

View File

@ -86,4 +86,29 @@ actual class NewPipeService {
) )
} }
} }
actual suspend fun getRadioMix(videoId: String): List<VideoSearchResult> {
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())
}
}
} }

View File

@ -1,7 +1,7 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.0-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.0-all.zip
networkTimeout=10000 networkTimeout=600000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists