mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-04 10:13:59 +00:00
fix(downloads): respect download format/quality and never save manifests or video
- Downloads resolve streams with the user's download format/quality via a new StreamRequest instead of the streaming settings, and bypass the playback stream-URL cache in both directions - Downloads only consider progressive streams; DASH/HLS manifests were previously saved as-is instead of audio - NewPipe: map AAC "mp4a.40.x"/"m4a" to the advertised "aac"/"mp4" so real AAC streams can match (previously the hard-coded aac/mp4 manifests always won), report each stream's real delivery method, skip streams without a format or non-URL content - Fail the download on non-2xx responses, on a range request answered with a full 200, and on video/* content, with localized reasons, instead of writing the response body to disk - Save AAC as .m4a - Tests for request overrides, manifest exclusion and cache isolation
This commit is contained in:
parent
8bbefdfb7d
commit
08892c2aa0
@ -26,6 +26,7 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.schabi.newpipe.extractor.ServiceList.YouTube
|
import org.schabi.newpipe.extractor.ServiceList.YouTube
|
||||||
import org.schabi.newpipe.extractor.services.youtube.linkHandler.YoutubeSearchQueryHandlerFactory
|
import org.schabi.newpipe.extractor.services.youtube.linkHandler.YoutubeSearchQueryHandlerFactory
|
||||||
|
import org.schabi.newpipe.extractor.stream.DeliveryMethod
|
||||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||||
|
|
||||||
/** NewPipe reports durations in seconds (-1 when unknown). */
|
/** NewPipe reports durations in seconds (-1 when unknown). */
|
||||||
@ -88,11 +89,15 @@ actual class NewPipeService {
|
|||||||
|
|
||||||
val audioStreams = extractor.audioStreams
|
val audioStreams = extractor.audioStreams
|
||||||
.mapNotNull { stream ->
|
.mapNotNull { stream ->
|
||||||
|
val format = stream.format ?: return@mapNotNull null
|
||||||
|
// Non-URL content is an inline manifest, not something a player or downloader can fetch.
|
||||||
|
if (!stream.isUrl) return@mapNotNull null
|
||||||
AudioStream.Lossy(
|
AudioStream.Lossy(
|
||||||
url = stream.content,
|
url = stream.content,
|
||||||
codec = stream.codec,
|
codec = normalizeCodec(stream.codec),
|
||||||
container = stream.format!!.suffix,
|
container = normalizeContainer(format.suffix),
|
||||||
bitrate = stream.bitrate)
|
bitrate = stream.bitrate,
|
||||||
|
protocol = stream.deliveryMethod.toProtocol())
|
||||||
} + buildList {
|
} + buildList {
|
||||||
if (extractor.dashMpdUrl.isNotBlank()) add(
|
if (extractor.dashMpdUrl.isNotBlank()) add(
|
||||||
AudioStream.Lossy(
|
AudioStream.Lossy(
|
||||||
@ -151,3 +156,22 @@ actual class NewPipeService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NewPipe names AAC "mp4a.40.x" in an "m4a" container, but the plugin advertises (and users pick)
|
||||||
|
* "aac"/"mp4". Without this a real AAC stream never matched the preference, and the hard-coded
|
||||||
|
* aac/mp4 DASH/HLS manifests always won instead.
|
||||||
|
*/
|
||||||
|
private fun normalizeCodec(codec: String?): String {
|
||||||
|
val value = codec.orEmpty()
|
||||||
|
return if (value.startsWith("mp4a", ignoreCase = true)) "aac" else value
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizeContainer(suffix: String): String =
|
||||||
|
if (suffix.equals("m4a", ignoreCase = true)) "mp4" else suffix
|
||||||
|
|
||||||
|
private fun DeliveryMethod.toProtocol(): StreamProtocol = when (this) {
|
||||||
|
DeliveryMethod.DASH -> StreamProtocol.DASH
|
||||||
|
DeliveryMethod.HLS -> StreamProtocol.HLS
|
||||||
|
else -> StreamProtocol.PROGRESSIVE
|
||||||
|
}
|
||||||
|
|||||||
@ -710,6 +710,9 @@
|
|||||||
<string name="downloads_error_no_metadata">No track metadata</string>
|
<string name="downloads_error_no_metadata">No track metadata</string>
|
||||||
<string name="downloads_error_stream_unresolved_short">Could not resolve stream URL</string>
|
<string name="downloads_error_stream_unresolved_short">Could not resolve stream URL</string>
|
||||||
<string name="downloads_error_stream_unresolved">Could not resolve stream URL. Try playing the track first.</string>
|
<string name="downloads_error_stream_unresolved">Could not resolve stream URL. Try playing the track first.</string>
|
||||||
|
<string name="downloads_error_http">The server refused the download (HTTP %1$d)</string>
|
||||||
|
<string name="downloads_error_range_ignored">The server sent an incomplete download. Try again.</string>
|
||||||
|
<string name="downloads_error_video_only">Only a video is available for this track, so it wasn\'t downloaded</string>
|
||||||
|
|
||||||
<!-- Saved tracks -->
|
<!-- Saved tracks -->
|
||||||
<string name="saved_tracks_title">Saved Tracks</string>
|
<string name="saved_tracks_title">Saved Tracks</string>
|
||||||
@ -962,7 +965,6 @@
|
|||||||
<string name="app_window_close">Close</string>
|
<string name="app_window_close">Close</string>
|
||||||
|
|
||||||
<!-- Android playback notification -->
|
<!-- Android playback notification -->
|
||||||
<string name="notification_playback_service_text">Playback service</string>
|
|
||||||
<string name="notification_playback_channel_name">Playback</string>
|
<string name="notification_playback_channel_name">Playback</string>
|
||||||
<string name="notification_playback_channel_description">Media playback controls</string>
|
<string name="notification_playback_channel_description">Media playback controls</string>
|
||||||
|
|
||||||
|
|||||||
@ -54,6 +54,16 @@ private data class CachedAlternatives(
|
|||||||
val pluginId: String,
|
val pluginId: String,
|
||||||
val sources: List<AudioSource>)
|
val sources: List<AudioSource>)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream preferences for callers other than playback. The downloader passes the user's *download*
|
||||||
|
* format/quality and needs a file it can fetch with plain HTTP, so DASH/HLS manifests are excluded.
|
||||||
|
* Such requests bypass the playback stream-URL cache in both directions.
|
||||||
|
*/
|
||||||
|
data class StreamRequest(
|
||||||
|
val format: AudioFormat,
|
||||||
|
val quality: AudioQuality,
|
||||||
|
val progressiveOnly: Boolean)
|
||||||
|
|
||||||
data class StreamInfo(
|
data class StreamInfo(
|
||||||
val url: String,
|
val url: String,
|
||||||
val protocol: StreamProtocol,
|
val protocol: StreamProtocol,
|
||||||
@ -90,26 +100,44 @@ class StreamingUrlRepository(
|
|||||||
|
|
||||||
suspend fun resolveStreamInfo(
|
suspend fun resolveStreamInfo(
|
||||||
track: MetadataTrack,
|
track: MetadataTrack,
|
||||||
forceRefresh: Boolean = false
|
forceRefresh: Boolean = false,
|
||||||
|
request: StreamRequest? = null
|
||||||
): StreamInfo? {
|
): StreamInfo? {
|
||||||
val trackId = track.id
|
val trackId = track.id
|
||||||
val settings = settingsRepository.userSettings.value
|
val settings = settingsRepository.userSettings.value
|
||||||
val preferredFormat = settings.streamingMusicFormat
|
val preferredFormat = request?.format ?: settings.streamingMusicFormat
|
||||||
val preferredQuality = settings.streamingMusicQuality
|
val preferredQuality = request?.quality ?: settings.streamingMusicQuality
|
||||||
|
// The URL cache holds what *playback* resolved; a download with other prefs must not
|
||||||
|
// read it, nor replace it with a stream playback didn't ask for.
|
||||||
|
val useUrlCache = request == null
|
||||||
|
|
||||||
val audioPlugin = pluginManager.selectedAudioPlugin.value
|
val audioPlugin = pluginManager.selectedAudioPlugin.value
|
||||||
if (audioPlugin == null) {
|
if (audioPlugin == null) {
|
||||||
logger.w { "No audio plugin selected while resolving stream for track $trackId" }
|
logger.w { "No audio plugin selected while resolving stream for track $trackId" }
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
// NOTE: always the *streaming* preferences, also when called by the downloader.
|
|
||||||
logger.i {
|
logger.i {
|
||||||
"[Resolve $trackId] '${track.title}' plugin=${audioPlugin.pluginId} forceRefresh=$forceRefresh " +
|
"[Resolve $trackId] '${track.title}' plugin=${audioPlugin.pluginId} forceRefresh=$forceRefresh " +
|
||||||
"prefs: format=${preferredFormat.codec}/${preferredFormat.container} quality=$preferredQuality " +
|
"prefs: format=${preferredFormat.codec}/${preferredFormat.container} quality=$preferredQuality " +
|
||||||
"(streamingMusicFormat/streamingMusicQuality)"
|
if (request == null) "(streaming settings)" else "(caller request, progressiveOnly=${request.progressiveOnly})"
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!forceRefresh) {
|
fun select(stream: AudioSource.Streamed, origin: String): AudioStream? {
|
||||||
|
val candidates = if (request?.progressiveOnly == true) {
|
||||||
|
stream.streams.filter { it.protocol == StreamProtocol.PROGRESSIVE }.also { kept ->
|
||||||
|
if (kept.size != stream.streams.size) logger.i {
|
||||||
|
"[Resolve $trackId] dropped ${stream.streams.size - kept.size} manifest (DASH/HLS) candidates; " +
|
||||||
|
"caller needs a directly downloadable file"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
stream.streams
|
||||||
|
}
|
||||||
|
return selectPreferredAudioStream(candidates, preferredFormat, preferredQuality)
|
||||||
|
.also { logSelection(trackId, origin, candidates, preferredFormat, preferredQuality, it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (useUrlCache && !forceRefresh) {
|
||||||
getCachedStreamUrl(trackId, preferredFormat, preferredQuality, audioPlugin.pluginId)?.let { cached ->
|
getCachedStreamUrl(trackId, preferredFormat, preferredQuality, audioPlugin.pluginId)?.let { cached ->
|
||||||
logger.i {
|
logger.i {
|
||||||
"[Resolve $trackId] cache hit: codec=${cached.codec} container=${cached.container} " +
|
"[Resolve $trackId] cache hit: codec=${cached.codec} container=${cached.container} " +
|
||||||
@ -135,14 +163,12 @@ class StreamingUrlRepository(
|
|||||||
|
|
||||||
if (stream != null) {
|
if (stream != null) {
|
||||||
logger.d { "Resolved stream for track $trackId using cached source" }
|
logger.d { "Resolved stream for track $trackId using cached source" }
|
||||||
val resolvedStream = selectPreferredAudioStream(
|
val resolvedStream = select(stream, "matched source ${source.id}")
|
||||||
stream.streams,
|
|
||||||
preferredFormat,
|
|
||||||
preferredQuality)
|
|
||||||
logSelection(trackId, "matched source ${source.id}", stream.streams, preferredFormat, preferredQuality, resolvedStream)
|
|
||||||
if (resolvedStream != null) {
|
if (resolvedStream != null) {
|
||||||
val info = resolvedStream.toStreamInfo()
|
val info = resolvedStream.toStreamInfo()
|
||||||
cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId)
|
if (useUrlCache) {
|
||||||
|
cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId)
|
||||||
|
}
|
||||||
return info
|
return info
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -195,14 +221,12 @@ class StreamingUrlRepository(
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.d { "Resolved stream for track $trackId via track lookup" }
|
logger.d { "Resolved stream for track $trackId via track lookup" }
|
||||||
val resolvedStream = selectPreferredAudioStream(
|
val resolvedStream = select(stream, "track lookup -> ${stream.id}")
|
||||||
stream.streams,
|
|
||||||
preferredFormat,
|
|
||||||
preferredQuality)
|
|
||||||
logSelection(trackId, "track lookup -> ${stream.id}", stream.streams, preferredFormat, preferredQuality, resolvedStream)
|
|
||||||
if (resolvedStream != null) {
|
if (resolvedStream != null) {
|
||||||
val info = resolvedStream.toStreamInfo()
|
val info = resolvedStream.toStreamInfo()
|
||||||
cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId)
|
if (useUrlCache) {
|
||||||
|
cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId)
|
||||||
|
}
|
||||||
return info
|
return info
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
|
|||||||
@ -21,6 +21,7 @@ import co.touchlab.kermit.Logger
|
|||||||
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||||
import org.spotube.spotube.core.paths.Paths
|
import org.spotube.spotube.core.paths.Paths
|
||||||
import org.spotube.spotube.core.server.StreamInfo
|
import org.spotube.spotube.core.server.StreamInfo
|
||||||
|
import org.spotube.spotube.core.server.StreamRequest
|
||||||
import org.spotube.spotube.core.server.StreamingUrlRepository
|
import org.spotube.spotube.core.server.StreamingUrlRepository
|
||||||
import org.spotube.spotube.core.server.urlSummary
|
import org.spotube.spotube.core.server.urlSummary
|
||||||
import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol
|
import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol
|
||||||
@ -32,6 +33,7 @@ import io.ktor.client.request.head
|
|||||||
import io.ktor.client.request.headers
|
import io.ktor.client.request.headers
|
||||||
import io.ktor.client.statement.bodyAsChannel
|
import io.ktor.client.statement.bodyAsChannel
|
||||||
import io.ktor.http.HttpHeaders
|
import io.ktor.http.HttpHeaders
|
||||||
|
import io.ktor.http.HttpStatusCode
|
||||||
import io.ktor.http.contentLength
|
import io.ktor.http.contentLength
|
||||||
import io.ktor.http.isSuccess
|
import io.ktor.http.isSuccess
|
||||||
import io.ktor.utils.io.readRemaining
|
import io.ktor.utils.io.readRemaining
|
||||||
@ -226,7 +228,14 @@ class DownloadManager(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
logger.i { "[Download ${item.id}] resolving stream for '${track.title}'" }
|
logger.i { "[Download ${item.id}] resolving stream for '${track.title}'" }
|
||||||
val streamInfo = streamingUrlRepository.resolveStreamInfo(track)
|
val settings = settingsRepository.userSettings.value
|
||||||
|
val streamInfo = streamingUrlRepository.resolveStreamInfo(
|
||||||
|
track,
|
||||||
|
request = StreamRequest(
|
||||||
|
format = settings.downloadMusicFormat,
|
||||||
|
quality = settings.downloadMusicQuality,
|
||||||
|
// A manifest URL would be saved as-is instead of the audio it points to.
|
||||||
|
progressiveOnly = true))
|
||||||
if (streamInfo == null) {
|
if (streamInfo == null) {
|
||||||
logger.w { "Could not resolve stream URL for ${track.title}" }
|
logger.w { "Could not resolve stream URL for ${track.title}" }
|
||||||
val unresolvedStatus = getString(Res.string.downloads_error_stream_unresolved_short)
|
val unresolvedStatus = getString(Res.string.downloads_error_stream_unresolved_short)
|
||||||
@ -358,9 +367,15 @@ class DownloadManager(
|
|||||||
"HTTP ${response.status.value} type=${response.headers[HttpHeaders.ContentType]} " +
|
"HTTP ${response.status.value} type=${response.headers[HttpHeaders.ContentType]} " +
|
||||||
"range=${response.headers[HttpHeaders.ContentRange]}"
|
"range=${response.headers[HttpHeaders.ContentRange]}"
|
||||||
}
|
}
|
||||||
// expectSuccess = false, so an error page would otherwise be saved as audio.
|
// expectSuccess = false, so without these checks an error page, or the whole file once per
|
||||||
|
// segment (a 200 to a range request), would be written into the "audio" file.
|
||||||
if (!response.status.isSuccess()) {
|
if (!response.status.isSuccess()) {
|
||||||
logger.w { "[Download ${item.id}] segment ${segment.index} got HTTP ${response.status}; its body will be written as audio" }
|
logger.w { "[Download ${item.id}] segment ${segment.index} got HTTP ${response.status}; failing download" }
|
||||||
|
throw DownloadException(getString(Res.string.downloads_error_http, response.status.value))
|
||||||
|
}
|
||||||
|
if (response.status != HttpStatusCode.PartialContent) {
|
||||||
|
logger.w { "[Download ${item.id}] segment ${segment.index} got ${response.status} instead of 206; range ignored" }
|
||||||
|
throw DownloadException(getString(Res.string.downloads_error_range_ignored))
|
||||||
}
|
}
|
||||||
val channel = response.bodyAsChannel()
|
val channel = response.bodyAsChannel()
|
||||||
|
|
||||||
@ -389,8 +404,10 @@ class DownloadManager(
|
|||||||
"type=${response.headers[HttpHeaders.ContentType]} length=$totalSize"
|
"type=${response.headers[HttpHeaders.ContentType]} length=$totalSize"
|
||||||
}
|
}
|
||||||
if (!response.status.isSuccess()) {
|
if (!response.status.isSuccess()) {
|
||||||
logger.w { "[Download ${item.id}] GET got HTTP ${response.status}; its body will be written as audio" }
|
logger.w { "[Download ${item.id}] GET got HTTP ${response.status}; failing download" }
|
||||||
|
throw DownloadException(getString(Res.string.downloads_error_http, response.status.value))
|
||||||
}
|
}
|
||||||
|
rejectVideo(item.id, response.headers[HttpHeaders.ContentType])
|
||||||
val channel = response.bodyAsChannel()
|
val channel = response.bodyAsChannel()
|
||||||
|
|
||||||
fileSystem.sink(tempFile).buffer().use { sink ->
|
fileSystem.sink(tempFile).buffer().use { sink ->
|
||||||
@ -433,16 +450,27 @@ class DownloadManager(
|
|||||||
"[Download $id] HEAD -> HTTP ${response.status.value} type=$contentType " +
|
"[Download $id] HEAD -> HTTP ${response.status.value} type=$contentType " +
|
||||||
"length=$contentLength acceptRanges=$acceptRanges"
|
"length=$contentLength acceptRanges=$acceptRanges"
|
||||||
}
|
}
|
||||||
if (contentType?.startsWith("video/") == true) {
|
rejectVideo(id, contentType)
|
||||||
logger.w { "[Download $id] server says this is VIDEO ($contentType), not audio" }
|
|
||||||
}
|
|
||||||
Pair(contentLength, supportsRanges)
|
Pair(contentLength, supportsRanges)
|
||||||
|
} catch (e: DownloadException) {
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.w(e) { "[Download $id] HEAD request failed, falling back to single connection" }
|
logger.w(e) { "[Download $id] HEAD request failed, falling back to single connection" }
|
||||||
Pair(0L, false)
|
Pair(0L, false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Some sources only offer muxed video (e.g. NewPipe falls back to video streams when a video
|
||||||
|
* has no audio-only ones). Saving that as a "song" is worse than failing with a clear reason.
|
||||||
|
*/
|
||||||
|
private suspend fun rejectVideo(id: String, contentType: String?) {
|
||||||
|
if (contentType?.startsWith("video/", ignoreCase = true) == true) {
|
||||||
|
logger.w { "[Download $id] server says this is VIDEO ($contentType), not audio; failing download" }
|
||||||
|
throw DownloadException(getString(Res.string.downloads_error_video_only))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun logResolvedStream(item: DownloadItem, info: StreamInfo) {
|
private fun logResolvedStream(item: DownloadItem, info: StreamInfo) {
|
||||||
val settings = settingsRepository.userSettings.value
|
val settings = settingsRepository.userSettings.value
|
||||||
val wanted = settings.downloadMusicFormat
|
val wanted = settings.downloadMusicFormat
|
||||||
@ -457,9 +485,8 @@ class DownloadManager(
|
|||||||
}
|
}
|
||||||
if (!info.codec.equals(wanted.codec, ignoreCase = true) || !info.container.equals(wanted.container, ignoreCase = true)) {
|
if (!info.codec.equals(wanted.codec, ignoreCase = true) || !info.container.equals(wanted.container, ignoreCase = true)) {
|
||||||
logger.w {
|
logger.w {
|
||||||
"[Download ${item.id}] DOWNLOAD FORMAT NOT RESPECTED: download setting is ${wanted.codec}/${wanted.container} " +
|
"[Download ${item.id}] FORMAT FALLBACK: download setting is ${wanted.codec}/${wanted.container} " +
|
||||||
"${settings.downloadMusicQuality}, got ${info.codec}/${info.container} " +
|
"${settings.downloadMusicQuality}, but the closest downloadable stream offered is ${info.codec}/${info.container}"
|
||||||
"(resolver used streaming prefs ${settings.streamingMusicFormat.codec}/${settings.streamingMusicFormat.container})"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -525,13 +552,21 @@ class DownloadManager(
|
|||||||
private fun buildFilename(downloadItem: DownloadItem, streamInfo: StreamInfo): String {
|
private fun buildFilename(downloadItem: DownloadItem, streamInfo: StreamInfo): String {
|
||||||
val safeTitle = downloadItem.title.sanitizeFilename()
|
val safeTitle = downloadItem.title.sanitizeFilename()
|
||||||
val safeArtist = downloadItem.artists.sanitizeFilename()
|
val safeArtist = downloadItem.artists.sanitizeFilename()
|
||||||
val extension = streamInfo.container
|
val extension = fileExtension(streamInfo)
|
||||||
return "$safeArtist - $safeTitle.$extension"
|
return "$safeArtist - $safeTitle.$extension"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** AAC in an MP4 container is audio-only, so give it the audio extension players expect. */
|
||||||
|
private fun fileExtension(info: StreamInfo): String =
|
||||||
|
if (info.codec.equals("aac", ignoreCase = true) && info.container.equals("mp4", ignoreCase = true)) "m4a"
|
||||||
|
else info.container
|
||||||
|
|
||||||
private fun String.sanitizeFilename(): String {
|
private fun String.sanitizeFilename(): String {
|
||||||
return this.replace(Regex("[^a-zA-Z0-9.\\-_' ]"), "_").take(200)
|
return this.replace(Regex("[^a-zA-Z0-9.\\-_' ]"), "_").take(200)
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class Segment(val index: Int, val start: Long, val end: Long)
|
private data class Segment(val index: Int, val start: Long, val end: Long)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A download failure whose [message] is already localized and safe to show the user. */
|
||||||
|
private class DownloadException(message: String) : Exception(message)
|
||||||
|
|||||||
@ -545,4 +545,68 @@ class StreamingUrlRepositoryBehaviorTest {
|
|||||||
// Fetching the entry doesn't call the plugin again.
|
// Fetching the entry doesn't call the plugin again.
|
||||||
assertEquals(1, plugin.audioAPI.getStreamsByTrackCallCount)
|
assertEquals(1, plugin.audioAPI.getStreamsByTrackCallCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- caller requests (downloads) ----------
|
||||||
|
|
||||||
|
private val aacRequest = StreamRequest(
|
||||||
|
format = org.spotube.plugin_interfaces.plugin_apis.audio.AudioFormat(
|
||||||
|
codec = "aac", container = "mp4", qualities = listOf(AudioQuality.Lossy(128_000))),
|
||||||
|
quality = AudioQuality.Lossy(128_000),
|
||||||
|
progressiveOnly = true)
|
||||||
|
|
||||||
|
private fun aacStream(url: String, protocol: StreamProtocol = StreamProtocol.PROGRESSIVE) = AudioStream.Lossy(
|
||||||
|
url = url, codec = "aac", container = "mp4", protocol = protocol, bitrate = 128_000)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a request's format overrides the streaming settings`() = runTest {
|
||||||
|
val h = Harness()
|
||||||
|
val plugin = h.selectPlugin("pluginA")
|
||||||
|
plugin.audioAPI.onGetStreamsByTrack = {
|
||||||
|
listOf(streamedSource("src1", listOf(stream("https://example.com/a.opus"), aacStream("https://example.com/a.m4a"))))
|
||||||
|
}
|
||||||
|
|
||||||
|
val info = h.repository.resolveStreamInfo(track("t1"), request = aacRequest)
|
||||||
|
|
||||||
|
assertEquals("https://example.com/a.m4a", info?.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `progressiveOnly never picks a manifest even when it matches best`() = runTest {
|
||||||
|
val h = Harness()
|
||||||
|
val plugin = h.selectPlugin("pluginA")
|
||||||
|
plugin.audioAPI.onGetStreamsByTrack = {
|
||||||
|
listOf(streamedSource("src1", listOf(
|
||||||
|
aacStream("https://example.com/manifest.mpd", StreamProtocol.DASH),
|
||||||
|
stream("https://example.com/a.opus"))))
|
||||||
|
}
|
||||||
|
|
||||||
|
val info = h.repository.resolveStreamInfo(track("t1"), request = aacRequest)
|
||||||
|
|
||||||
|
assertEquals("https://example.com/a.opus", info?.url)
|
||||||
|
assertEquals(StreamProtocol.PROGRESSIVE, info?.protocol)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `progressiveOnly returns null when only manifests exist`() = runTest {
|
||||||
|
val h = Harness()
|
||||||
|
val plugin = h.selectPlugin("pluginA")
|
||||||
|
plugin.audioAPI.onGetStreamsByTrack = {
|
||||||
|
listOf(streamedSource("src1", listOf(aacStream("https://example.com/manifest.m3u8", StreamProtocol.HLS))))
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNull(h.repository.resolveStreamInfo(track("t1"), request = aacRequest))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a request does not write the playback url cache`() = runTest {
|
||||||
|
val h = Harness()
|
||||||
|
val plugin = h.selectPlugin("pluginA")
|
||||||
|
plugin.audioAPI.onGetStreamsByTrack = {
|
||||||
|
listOf(streamedSource("src1", listOf(aacStream("https://example.com/a.m4a"))))
|
||||||
|
}
|
||||||
|
|
||||||
|
h.repository.resolveStreamInfo(track("t1"), request = aacRequest)
|
||||||
|
|
||||||
|
assertNull(h.repository.getCachedStreamUrlEntry("t1"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user