mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-04 10:13:59 +00:00
commit
a7e47d0e00
@ -19,9 +19,13 @@
|
||||
|
||||
## Dependencies
|
||||
- `gradle/libs.versions.toml` is the single source of truth for all version pins and library declarations.
|
||||
- Kotlin: `2.3.0`, JVM target: `11` (compile/target compatibility in both `composeApp/build.gradle.kts` and `plugin_interfaces/build.gradle.kts`).
|
||||
- Kotlin: `2.4.21`, JVM target: `11` (compile/target compatibility in both `composeApp/build.gradle.kts` and `plugin_interfaces/build.gradle.kts`).
|
||||
- **compose-webview (desktop) dispatcher hijack:** `dev.nucleusframework:composewebview` transitively pulls `nucleus.decorated-window-tao`, which registers a `TaoMainDispatcherFactory` (`loadPriority = 100`) via `META-INF/services` that hijacks `Dispatchers.Main` away from the Swing EDT. This breaks lifecycle's `enforceMainThreadIfNeeded` (window fails with "Method addObserver must be called on the main thread"). The app overrides it with `SwingMainDispatcherFactory` (`composeApp/src/jvmMain/.../core/coroutines/`, `loadPriority = Int.MAX_VALUE`) which pins `Dispatchers.Main` back to the Swing EDT. Keep that factory + its `META-INF/services/kotlinx.coroutines.internal.MainDispatcherFactory` resource; the long-term fix is in the webview (make the Tao dispatcher opt-in or lower its priority).
|
||||
|
||||
## Networking
|
||||
- Never call `HttpClient(...)` directly. Inject `HttpClientFactory` (`commonMain/.../core/network/HttpClientFactory.kt`, a Koin single) and use `.default` / `.json` (shared, never close them) or `.create { ... }` for extra plugins (caller owns and may close it). It owns the one engine-backed client, so the platform default engine is used everywhere (Darwin on iOS, OkHttp on Android/desktop).
|
||||
- Do not add `ktor-client-cio`: on iOS it competes with Darwin as the default engine and has no TLS on Kotlin/Native, so every HTTPS request fails.
|
||||
|
||||
## GraalVM native image (desktop)
|
||||
- Enabled via `graalvm { }` inside `nucleus.application` in `composeApp/build.gradle.kts`. Nucleus downloads GraalVM CE itself (or uses `$GRAALVM_HOME`). Linux needs `gcc` + `patchelf`.
|
||||
- Build: `:composeApp:createGraalvmNativeDistributable` (output `composeApp/build/compose/binaries/main/graalvm-app/`), installers via `packageGraalvm{Deb,Rpm,AppImage,Dmg,Msi,Exe}`. A build takes ~10 min and uses all cores and ~15 GB RAM.
|
||||
|
||||
@ -232,7 +232,6 @@ kotlin {
|
||||
implementation(libs.ktor.client.logging)
|
||||
implementation(libs.ktor.client.content.negotiation)
|
||||
implementation(libs.ktor.client.serialization.kotlinx.json)
|
||||
implementation(libs.ktor.client.cio)
|
||||
implementation(libs.ktor.client.websockets)
|
||||
implementation(libs.ktor.server.core)
|
||||
implementation(libs.ktor.server.cio)
|
||||
|
||||
@ -162,14 +162,6 @@ actual class NewPipeService {
|
||||
* "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
|
||||
|
||||
@ -17,6 +17,8 @@
|
||||
|
||||
package org.spotube.spotube.core.di
|
||||
|
||||
import org.koin.core.module.dsl.onClose
|
||||
import org.spotube.spotube.core.network.HttpClientFactory
|
||||
import co.touchlab.kermit.Logger
|
||||
import org.spotube.spotube.core.audioplayer.AudioPlayerQueue
|
||||
import org.spotube.spotube.core.audioplayer.AudioPlayerQueueRepository
|
||||
@ -108,6 +110,7 @@ expect val platformModules: Module
|
||||
val sharedModules = module {
|
||||
includes(navigationModule)
|
||||
singleOf(::Database)
|
||||
single { HttpClientFactory() } withOptions { onClose { it?.close() } }
|
||||
|
||||
singleOf(::WebViewController)
|
||||
|
||||
@ -164,7 +167,7 @@ val sharedModules = module {
|
||||
viewModelOf(::WelcomeViewModel)
|
||||
|
||||
// Updates
|
||||
single { AppUpdateService(get()) } withOptions { createdAtStart() }
|
||||
single { AppUpdateService(get(), get()) } withOptions { createdAtStart() }
|
||||
single { PluginUpdateService(get(), get()) } withOptions { createdAtStart() }
|
||||
|
||||
// Downloads
|
||||
@ -273,7 +276,7 @@ val sharedModules = module {
|
||||
createdAtStart()
|
||||
}
|
||||
single { RemoteControlHandler(get(), get(), get(), get()) }
|
||||
single { RemoteControlClient() }
|
||||
single { RemoteControlClient(get()) }
|
||||
singleOf(::DeviceDiscoveryService)
|
||||
single { RemoteControlService(get(), get(), get()) } withOptions {
|
||||
createdAtStart()
|
||||
|
||||
@ -40,7 +40,7 @@ import kotlinx.serialization.modules.SerializersModule
|
||||
import kotlinx.serialization.modules.polymorphic
|
||||
import org.koin.core.component.KoinComponent
|
||||
|
||||
val TOP_LEVEL_ROUTES = setOf(
|
||||
val TOP_LEVEL_ROUTES: Set<NavKey> = setOf(
|
||||
Routes.Home,
|
||||
Routes.Search,
|
||||
Routes.Library,
|
||||
|
||||
@ -0,0 +1,60 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.spotube.spotube.core.network
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.HttpClientConfig
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* The single place the app builds Ktor [HttpClient]s.
|
||||
*
|
||||
* It owns one client on the platform's default engine (Darwin on iOS, OkHttp on
|
||||
* Android/desktop), so every client shares that engine's connection pool and
|
||||
* no code path can accidentally pick an engine that doesn't work on a platform.
|
||||
*
|
||||
* - [default] and [json] are shared: use them directly and never close them.
|
||||
* - [create] derives a client with extra configuration (timeouts, WebSockets,
|
||||
* default request, …). It reuses the shared engine; the caller owns the
|
||||
* returned client and may close it without affecting anyone else.
|
||||
*/
|
||||
class HttpClientFactory : AutoCloseable {
|
||||
private val root = HttpClient()
|
||||
|
||||
/** Plain client with no plugins installed. */
|
||||
val default: HttpClient get() = root
|
||||
|
||||
/** Client that (de)serializes JSON bodies via [HttpJson]. */
|
||||
val json: HttpClient by lazy {
|
||||
create { install(ContentNegotiation) { json(HttpJson) } }
|
||||
}
|
||||
|
||||
fun create(block: HttpClientConfig<*>.() -> Unit): HttpClient = root.config(block)
|
||||
|
||||
override fun close() {
|
||||
root.close()
|
||||
}
|
||||
}
|
||||
|
||||
/** Lenient JSON used for HTTP bodies: remote APIs add fields over time. */
|
||||
val HttpJson = Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
}
|
||||
@ -49,4 +49,14 @@ data class MediaStream(
|
||||
val container: String,
|
||||
val bitrate: Int,
|
||||
val isAudio: Boolean,
|
||||
val isVideo: Boolean)
|
||||
val isVideo: Boolean)
|
||||
|
||||
/** Maps NewPipe's codec names onto the plugin's AudioFormat names (mp4a.40.2 → aac). */
|
||||
internal fun normalizeCodec(codec: String?): String {
|
||||
val value = codec.orEmpty()
|
||||
return if (value.startsWith("mp4a", ignoreCase = true)) "aac" else value
|
||||
}
|
||||
|
||||
/** Maps NewPipe's file suffixes onto the plugin's AudioFormat containers (m4a → mp4). */
|
||||
internal fun normalizeContainer(suffix: String): String =
|
||||
if (suffix.equals("m4a", ignoreCase = true)) "mp4" else suffix
|
||||
|
||||
@ -17,8 +17,8 @@
|
||||
|
||||
package org.spotube.spotube.core.remote
|
||||
|
||||
import org.spotube.spotube.core.network.HttpClientFactory
|
||||
import co.touchlab.kermit.Logger
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.plugins.HttpTimeout
|
||||
import io.ktor.client.request.header
|
||||
import io.ktor.client.request.url
|
||||
@ -49,7 +49,7 @@ import spotube.composeapp.generated.resources.devices_error_send_failed
|
||||
* WebSocket client for controlling a remote Spotube instance.
|
||||
* Connects to the remote device's `/control` endpoint and sends commands.
|
||||
*/
|
||||
class RemoteControlClient {
|
||||
class RemoteControlClient(httpClientFactory: HttpClientFactory) {
|
||||
private val logger = Logger.withTag("RemoteControlClient")
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
@ -59,7 +59,7 @@ class RemoteControlClient {
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
private val httpClient = HttpClient {
|
||||
private val httpClient = httpClientFactory.create {
|
||||
install(WebSockets)
|
||||
install(HttpTimeout) {
|
||||
connectTimeoutMillis = 10_000
|
||||
|
||||
@ -17,16 +17,15 @@
|
||||
|
||||
package org.spotube.spotube.core.server
|
||||
|
||||
import org.spotube.spotube.core.network.HttpClientFactory
|
||||
import org.spotube.spotube.core.audioplayer.AudioPlayerQueue
|
||||
import org.spotube.spotube.core.di.injectLogger
|
||||
import org.spotube.spotube.core.remote.RemoteControlHandler
|
||||
import org.spotube.spotube.modules.settings.SettingsViewModel
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.plugins.HttpTimeout
|
||||
import io.ktor.http.HttpMethod
|
||||
import io.ktor.server.application.Application
|
||||
import io.ktor.server.application.install
|
||||
import io.ktor.server.cio.CIO
|
||||
import io.ktor.server.engine.EmbeddedServer
|
||||
import io.ktor.server.engine.embeddedServer
|
||||
import io.ktor.server.response.respondText
|
||||
@ -60,11 +59,12 @@ class LocalServer(
|
||||
private val streamingUrlRepository: StreamingUrlRepository,
|
||||
private val audioPlayerQueue: AudioPlayerQueue,
|
||||
private val cacheManager: CacheManager,
|
||||
private val remoteControlHandler: RemoteControlHandler) : KoinComponent {
|
||||
private val remoteControlHandler: RemoteControlHandler,
|
||||
httpClientFactory: HttpClientFactory) : KoinComponent {
|
||||
|
||||
val logger by injectLogger<LocalServer>()
|
||||
private val httpClient = HttpClient {
|
||||
// A stalled upstream connection must not wedge the CIO dispatcher thread
|
||||
private val httpClient = httpClientFactory.create {
|
||||
// A stalled upstream connection must not wedge an engine thread
|
||||
// forever. Only the connect phase is bounded — the proxy streams long
|
||||
// audio bodies, so request/socket timeouts would cut playback short.
|
||||
install(HttpTimeout) {
|
||||
|
||||
@ -21,7 +21,10 @@ package org.spotube.spotube.core.server
|
||||
import io.ktor.server.application.Application
|
||||
import io.ktor.server.cio.CIO
|
||||
import io.ktor.server.engine.EmbeddedServer
|
||||
import io.ktor.server.engine.connector
|
||||
import io.ktor.server.engine.embeddedServer
|
||||
import io.ktor.server.application.serverConfig
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.withTimeout
|
||||
@ -37,6 +40,9 @@ internal class BoundServer(val engine: EmbeddedServer<*, *>, val port: Int, val
|
||||
* preferred port can't be bound, retry on an OS-assigned free port (port 0).
|
||||
*
|
||||
* The caller must read [BoundServer.port] instead of assuming [preferredPort].
|
||||
*
|
||||
* `reuseAddress` lets a restarted app rebind its old port while connections from the previous
|
||||
* run (e.g. the player's) are still in TIME_WAIT.
|
||||
*/
|
||||
internal suspend fun startServerWithPortFallback(
|
||||
host: String,
|
||||
@ -47,8 +53,25 @@ internal suspend fun startServerWithPortFallback(
|
||||
val candidates = listOf(preferredPort, 0).distinct()
|
||||
var lastFailure: Throwable? = null
|
||||
|
||||
// CIO reports a failed bind from its accept coroutine. Unhandled, that exception kills the
|
||||
// process on Kotlin/Native (iOS) before the fallback below gets to run; the failure still
|
||||
// reaches us through resolvedConnectors(), so it only needs to be contained here.
|
||||
val swallowBindFailure = CoroutineExceptionHandler { _, _ -> }
|
||||
|
||||
for (candidate in candidates) {
|
||||
val engine = embeddedServer(factory = CIO, host = host, port = candidate, module = module)
|
||||
val engine = embeddedServer(
|
||||
factory = CIO,
|
||||
rootConfig = serverConfig {
|
||||
parentCoroutineContext = swallowBindFailure
|
||||
module { module() }
|
||||
},
|
||||
configure = {
|
||||
connector {
|
||||
this.host = host
|
||||
this.port = candidate
|
||||
}
|
||||
reuseAddress = true
|
||||
})
|
||||
try {
|
||||
engine.start(wait = false)
|
||||
val boundPort = withTimeout(5.seconds) { engine.engine.resolvedConnectors().first().port }
|
||||
|
||||
@ -23,13 +23,18 @@ import org.spotube.plugin_interfaces.plugin_apis.audio.AudioQuality
|
||||
import org.spotube.plugin_interfaces.plugin_apis.audio.AudioStream
|
||||
import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol
|
||||
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||
import org.spotube.spotube.PlatformType
|
||||
import org.spotube.spotube.core.audioplayer.AudioPlayerQueue
|
||||
import org.spotube.spotube.core.audioplayer.QueueEntry
|
||||
import org.spotube.spotube.core.di.injectLogger
|
||||
import org.spotube.spotube.getPlatform
|
||||
import org.spotube.spotube.modules.plugin.AudioPluginSource
|
||||
import org.spotube.spotube.modules.settings.UserSettingsSource
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import org.koin.core.component.KoinComponent
|
||||
import kotlin.math.abs
|
||||
import kotlin.time.Clock
|
||||
@ -111,7 +116,14 @@ class StreamingUrlRepository(
|
||||
// read it, nor replace it with a stream playback didn't ask for.
|
||||
val useUrlCache = request == null
|
||||
|
||||
// A built-in audio plugin is always selected, so null means plugins are still loading.
|
||||
// The player requests the restored queue's track right at startup; failing that request
|
||||
// leaves the item permanently failed (AVPlayer), so wait for the plugin instead.
|
||||
val audioPlugin = pluginManager.selectedAudioPlugin.value
|
||||
?: withTimeoutOrNull(AUDIO_PLUGIN_LOAD_TIMEOUT) {
|
||||
logger.i { "[Resolve $trackId] waiting for the audio plugin to load" }
|
||||
pluginManager.selectedAudioPlugin.filterNotNull().first()
|
||||
}
|
||||
if (audioPlugin == null) {
|
||||
logger.w { "No audio plugin selected while resolving stream for track $trackId" }
|
||||
return null
|
||||
@ -243,10 +255,12 @@ class StreamingUrlRepository(
|
||||
logger.i {
|
||||
"[Resolve $trackId] selecting from ${streams.size} candidates ($origin), " +
|
||||
"want ${preferredFormat.codec}/${preferredFormat.container} $preferredQuality " +
|
||||
"(rank key = formatMismatch, qualityTypeMismatch, qualityDistance, channelDistance; lowest wins):\n" +
|
||||
"(rank key = unplayable, formatMismatch, qualityTypeMismatch, qualityDistance, channelDistance; " +
|
||||
"lowest wins):\n" +
|
||||
streams.joinToString("\n") { stream ->
|
||||
val marker = if (stream === selected) " -> " else " "
|
||||
"$marker${stream.describeForLog()} key=(${formatMismatchCount(stream, preferredFormat)}, " +
|
||||
"$marker${stream.describeForLog()} key=(${if (stream.isPlayableOn(currentPlatform)) 0 else 1}, " +
|
||||
"${formatMismatchCount(stream, preferredFormat)}, " +
|
||||
"${if (stream.matchesQualityType(preferredQuality)) 0 else 1}, " +
|
||||
"${stream.qualityDistance(preferredQuality).let { if (it == Long.MAX_VALUE) "inf" else "$it" }}, " +
|
||||
"${stream.channelDistance(preferredQuality)}) ${urlSummary(stream.url)}"
|
||||
@ -254,6 +268,10 @@ class StreamingUrlRepository(
|
||||
}
|
||||
when {
|
||||
selected == null -> logger.w { "[Resolve $trackId] no stream selected" }
|
||||
!selected.isPlayableOn(currentPlatform) -> logger.w {
|
||||
"[Resolve $trackId] NO PLAYABLE STREAM on $currentPlatform; falling back to " +
|
||||
"${selected.describeForLog()}, which the player will likely reject"
|
||||
}
|
||||
formatMismatchCount(selected, preferredFormat) > 0 -> logger.w {
|
||||
"[Resolve $trackId] FORMAT FALLBACK: wanted ${preferredFormat.codec}/${preferredFormat.container}, " +
|
||||
"got ${selected.codec}/${selected.container} (${selected.protocol})"
|
||||
@ -371,6 +389,8 @@ class StreamingUrlRepository(
|
||||
* Resolves a stream URL to an absolute URL. Relative manifest paths (DASH/HLS) are
|
||||
* resolved against the YouTube origin; progressive URLs are returned unchanged.
|
||||
*/
|
||||
private val AUDIO_PLUGIN_LOAD_TIMEOUT = 30.seconds
|
||||
|
||||
internal fun normalizeManifestUrl(url: String, protocol: StreamProtocol): String {
|
||||
if (url.startsWith("http")) return url
|
||||
return when (protocol) {
|
||||
@ -379,12 +399,21 @@ internal fun normalizeManifestUrl(url: String, protocol: StreamProtocol): String
|
||||
}
|
||||
}
|
||||
|
||||
/** Selects the available stream closest to the user's preferred format and quality. */
|
||||
internal val currentPlatform: PlatformType by lazy { getPlatform().type }
|
||||
|
||||
/**
|
||||
* Selects the available stream closest to the user's preferred format and quality.
|
||||
* Streams the [platform]'s player can decode always win over ones it can't, whatever the
|
||||
* preferences say: e.g. opus/webm is unplayable on iOS.
|
||||
*/
|
||||
internal fun selectPreferredAudioStream(
|
||||
streams: List<AudioStream>,
|
||||
preferredFormat: AudioFormat,
|
||||
preferredQuality: AudioQuality): AudioStream? = streams.minWithOrNull(
|
||||
preferredQuality: AudioQuality,
|
||||
platform: PlatformType = currentPlatform): AudioStream? = streams.minWithOrNull(
|
||||
compareBy<AudioStream> { stream ->
|
||||
if (stream.isPlayableOn(platform)) 0 else 1
|
||||
}.thenBy { stream ->
|
||||
formatMismatchCount(stream, preferredFormat)
|
||||
}.thenBy { stream ->
|
||||
if (stream.matchesQualityType(preferredQuality)) 0 else 1
|
||||
@ -395,6 +424,21 @@ internal fun selectPreferredAudioStream(
|
||||
}
|
||||
)
|
||||
|
||||
// AVPlayer has no WebM/Ogg/Matroska demuxer, so e.g. YouTube's opus/webm streams fail to load.
|
||||
private val IOS_UNSUPPORTED_CONTAINERS = setOf("webm", "ogg", "oga", "mkv", "matroska")
|
||||
|
||||
/**
|
||||
* Whether [platform]'s player can play this stream. VLC (desktop) and ExoPlayer (Android)
|
||||
* handle everything plugins return; AVPlayer (iOS) can't demux WebM/Ogg and can't play DASH.
|
||||
*/
|
||||
internal fun AudioStream.isPlayableOn(platform: PlatformType): Boolean =
|
||||
isContainerPlayableOn(container, platform) &&
|
||||
!(platform == PlatformType.IOS && protocol == StreamProtocol.DASH)
|
||||
|
||||
/** Whether [platform]'s player can open [container] at all; see [AudioStream.isPlayableOn]. */
|
||||
internal fun isContainerPlayableOn(container: String, platform: PlatformType): Boolean =
|
||||
platform != PlatformType.IOS || container.lowercase() !in IOS_UNSUPPORTED_CONTAINERS
|
||||
|
||||
private fun formatMismatchCount(stream: AudioStream, preferredFormat: AudioFormat): Int =
|
||||
(if (stream.codec.equals(preferredFormat.codec, ignoreCase = true)) 0 else 1) +
|
||||
(if (stream.container.equals(preferredFormat.container, ignoreCase = true)) 0 else 1)
|
||||
|
||||
@ -17,12 +17,13 @@
|
||||
|
||||
package org.spotube.spotube.core.tools.user_agents
|
||||
|
||||
import org.koin.core.component.inject
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.spotube.spotube.core.network.HttpClientFactory
|
||||
import co.touchlab.kermit.Logger
|
||||
import org.spotube.spotube.PlatformType
|
||||
import org.spotube.spotube.core.paths.Paths
|
||||
import org.spotube.spotube.getPlatform
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
import io.ktor.http.isSuccess
|
||||
@ -53,7 +54,8 @@ import kotlin.time.Clock
|
||||
* [random] is intentionally synchronous and never blocks on the network: it serves
|
||||
* whatever is cached (even stale) and refreshes in the background when needed.
|
||||
*/
|
||||
object UserAgents {
|
||||
object UserAgents : KoinComponent {
|
||||
private val httpClientFactory: HttpClientFactory by inject()
|
||||
private const val SOURCE_URL =
|
||||
"https://cdn.jsdelivr.net/npm/top-user-agents@latest/src/index.json"
|
||||
private const val CACHE_FILE_NAME = "user_agents_cache.json"
|
||||
@ -111,14 +113,9 @@ object UserAgents {
|
||||
private fun isValidAgent(ua: String) = ua.startsWith("Mozilla/")
|
||||
|
||||
private suspend fun fetchRemote(): List<String> {
|
||||
val client = HttpClient(CIO)
|
||||
try {
|
||||
val res = client.get(SOURCE_URL)
|
||||
if (!res.status.isSuccess()) error("Unexpected status ${res.status}")
|
||||
return json.decodeFromString(ListSerializer(String.serializer()), res.bodyAsText())
|
||||
} finally {
|
||||
client.close()
|
||||
}
|
||||
val res = httpClientFactory.default.get(SOURCE_URL)
|
||||
if (!res.status.isSuccess()) error("Unexpected status ${res.status}")
|
||||
return json.decodeFromString(ListSerializer(String.serializer()), res.bodyAsText())
|
||||
}
|
||||
|
||||
private fun cachePath() = (Paths().getApplicationCacheDirPath() + "/" + CACHE_FILE_NAME).toPath()
|
||||
|
||||
@ -40,7 +40,8 @@ class ZiplineDispatcher(
|
||||
* size is ~1 MiB, which causes native stack overflow when compiling large
|
||||
* JS modules like kotlin-stdlib (~491 KB).
|
||||
*
|
||||
* This expect/actual ensures the backing thread has a sufficiently large stack.
|
||||
* iOS secondary threads get only 512 KiB, so every platform must create its own
|
||||
* large-stack thread. This expect/actual ensures the backing thread has one.
|
||||
*/
|
||||
expect fun createZiplineDispatcher(): ZiplineDispatcher
|
||||
|
||||
|
||||
@ -17,11 +17,12 @@
|
||||
|
||||
package org.spotube.spotube.core.zipline.host_apis
|
||||
|
||||
import org.koin.core.component.inject
|
||||
import org.spotube.spotube.core.network.HttpClientFactory
|
||||
import org.spotube.plugin_interfaces.host_apis.HttpClientAPI
|
||||
import org.spotube.plugin_interfaces.host_apis.HttpMethod
|
||||
import org.spotube.plugin_interfaces.host_apis.HttpResponse
|
||||
import org.spotube.spotube.core.di.injectLogger
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.request.request
|
||||
import io.ktor.client.request.setBody
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
@ -30,7 +31,8 @@ import org.koin.core.component.KoinComponent
|
||||
|
||||
class RealHttpClientAPI : HttpClientAPI, KoinComponent {
|
||||
val logger by injectLogger<RealHttpClientAPI>()
|
||||
val httpClient = HttpClient()
|
||||
private val httpClientFactory: HttpClientFactory by inject()
|
||||
private val httpClient get() = httpClientFactory.default
|
||||
|
||||
override suspend fun request(
|
||||
method: HttpMethod,
|
||||
|
||||
@ -17,11 +17,14 @@
|
||||
|
||||
package org.spotube.spotube.core.zipline.plugin_apis.lrclib
|
||||
|
||||
import org.koin.core.component.inject
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.spotube.spotube.core.network.HttpJson
|
||||
import org.spotube.spotube.core.network.HttpClientFactory
|
||||
import org.spotube.plugin_interfaces.plugin_apis.lyrics.LyricsAPI
|
||||
import org.spotube.plugin_interfaces.plugin_apis.lyrics.LyricsLine
|
||||
import org.spotube.plugin_interfaces.plugin_apis.lyrics.LyricsResponse
|
||||
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.client.plugins.defaultRequest
|
||||
@ -36,7 +39,6 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.IO
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
@Serializable
|
||||
data class LRCLibResponse(
|
||||
@ -47,19 +49,22 @@ data class LRCLibResponse(
|
||||
val plainLyrics: String?,
|
||||
val syncedLyrics: String?)
|
||||
|
||||
class RealLRCLibLyricsAPI : LyricsAPI {
|
||||
private val httpClient = HttpClient {
|
||||
install(Logging) {
|
||||
level = LogLevel.ALL
|
||||
logger = Logger.SIMPLE
|
||||
}
|
||||
install(ContentNegotiation) {
|
||||
json(Json { ignoreUnknownKeys = true })
|
||||
}
|
||||
defaultRequest {
|
||||
url("https://lrclib.net/api/get")
|
||||
// TODO: Add the actual version of Spotube instead of hardcoding it
|
||||
userAgent("Spotube/v6.0.0 LRCLib Lyric Plugin")
|
||||
class RealLRCLibLyricsAPI : LyricsAPI, KoinComponent {
|
||||
private val httpClientFactory: HttpClientFactory by inject()
|
||||
private val httpClient by lazy {
|
||||
httpClientFactory.create {
|
||||
install(Logging) {
|
||||
level = LogLevel.ALL
|
||||
logger = Logger.SIMPLE
|
||||
}
|
||||
install(ContentNegotiation) {
|
||||
json(HttpJson)
|
||||
}
|
||||
defaultRequest {
|
||||
url("https://lrclib.net/api/get")
|
||||
// TODO: Add the actual version of Spotube instead of hardcoding it
|
||||
userAgent("Spotube/v6.0.0 LRCLib Lyric Plugin")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -17,15 +17,15 @@
|
||||
|
||||
package org.spotube.spotube.core.zipline.plugin_apis.newpipe_yt
|
||||
|
||||
import org.koin.core.component.inject
|
||||
import org.spotube.spotube.core.network.HttpClientFactory
|
||||
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||
import org.spotube.spotube.core.tools.user_agents.UserAgents
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.statement.HttpResponse
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
import org.spotube.spotube.core.di.injectLogger
|
||||
import org.koin.core.component.KoinComponent
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.parameter
|
||||
import io.ktor.client.request.post
|
||||
@ -54,15 +54,8 @@ class ISRCProviders : KoinComponent {
|
||||
.lowercase()
|
||||
}
|
||||
|
||||
val client = HttpClient() {
|
||||
install(ContentNegotiation) {
|
||||
json(Json {
|
||||
prettyPrint = true
|
||||
isLenient = true
|
||||
ignoreUnknownKeys = true
|
||||
})
|
||||
}
|
||||
}
|
||||
private val httpClientFactory: HttpClientFactory by inject()
|
||||
private val client get() = httpClientFactory.json
|
||||
|
||||
private fun MetadataTrack.describe() =
|
||||
"id=$id title='$title' artists=${artists.map { it.name }} album='${album?.title}' " +
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
|
||||
package org.spotube.spotube.modules.downloads
|
||||
|
||||
import org.spotube.spotube.core.network.HttpClientFactory
|
||||
import co.touchlab.kermit.Logger
|
||||
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||
import org.spotube.spotube.core.paths.Paths
|
||||
@ -26,7 +27,6 @@ import org.spotube.spotube.core.server.StreamingUrlRepository
|
||||
import org.spotube.spotube.core.server.urlSummary
|
||||
import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol
|
||||
import org.spotube.spotube.modules.settings.SettingsRepository
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.plugins.HttpTimeout
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.head
|
||||
@ -88,7 +88,8 @@ sealed interface DownloadStatus {
|
||||
class DownloadManager(
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val paths: Paths,
|
||||
private val streamingUrlRepository: StreamingUrlRepository
|
||||
private val streamingUrlRepository: StreamingUrlRepository,
|
||||
private val httpClientFactory: HttpClientFactory
|
||||
) {
|
||||
companion object {
|
||||
const val MAX_CONCURRENT_DOWNLOADS = 4
|
||||
@ -101,7 +102,7 @@ class DownloadManager(
|
||||
private val fileSystem = FileSystem.SYSTEM
|
||||
private val queueMutex = Mutex()
|
||||
|
||||
private val httpClient = HttpClient {
|
||||
private val httpClient = httpClientFactory.create {
|
||||
install(HttpTimeout) {
|
||||
requestTimeoutMillis = 300_000
|
||||
connectTimeoutMillis = 30_000
|
||||
|
||||
@ -17,9 +17,9 @@
|
||||
|
||||
package org.spotube.spotube.modules.plugin
|
||||
|
||||
import org.spotube.spotube.core.network.HttpClientFactory
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.headers
|
||||
import io.ktor.client.request.parameter
|
||||
@ -27,7 +27,6 @@ import io.ktor.http.append
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
@Serializable
|
||||
data class GitHubRepoSearchResponse(
|
||||
@ -64,12 +63,8 @@ data class GitHubAsset(
|
||||
val name: String,
|
||||
@SerialName("browser_download_url") val browserDownloadUrl: String)
|
||||
|
||||
class GitHubPluginRepository {
|
||||
private val httpClient = HttpClient {
|
||||
install(ContentNegotiation) {
|
||||
json(Json { ignoreUnknownKeys = true })
|
||||
}
|
||||
}
|
||||
/** [httpClient] must have JSON content negotiation installed (see [HttpClientFactory.json]). */
|
||||
class GitHubPluginRepository(private val httpClient: HttpClient) {
|
||||
|
||||
suspend fun searchSpotubePlugins(page: Int = 1, perPage: Int = 30): GitHubRepoSearchResponse {
|
||||
return httpClient.get("https://api.github.com/search/repositories") {
|
||||
@ -113,8 +108,4 @@ class GitHubPluginRepository {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
fun close() {
|
||||
httpClient.close()
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
|
||||
package org.spotube.spotube.modules.plugin
|
||||
|
||||
import org.spotube.spotube.core.network.HttpClientFactory
|
||||
import org.spotube.spotube.core.db.Database
|
||||
import org.spotube.spotube.core.db.DatabaseKeys
|
||||
import org.spotube.spotube.core.di.injectLogger
|
||||
@ -24,7 +25,6 @@ import org.spotube.spotube.core.paths.Paths
|
||||
import org.spotube.spotube.core.zipline.BuiltInPluginService
|
||||
import org.spotube.spotube.core.zipline.PluginService
|
||||
import org.spotube.spotube.core.zipline.ZiplinePluginService
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.statement.bodyAsChannel
|
||||
import io.ktor.utils.io.readRemaining
|
||||
@ -113,7 +113,8 @@ interface ScrobblePluginSource {
|
||||
|
||||
class PluginManager(
|
||||
val database: Database,
|
||||
val paths: Paths) : KoinComponent, PluginProvider, AudioPluginSource, ScrobblePluginSource {
|
||||
val paths: Paths,
|
||||
private val httpClientFactory: HttpClientFactory) : KoinComponent, PluginProvider, AudioPluginSource, ScrobblePluginSource {
|
||||
private val logger by injectLogger<PluginManager>()
|
||||
|
||||
// plugin.json may carry fields newer than this app knows about (e.g. `entryPoint`).
|
||||
@ -124,7 +125,7 @@ class PluginManager(
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate + pluginExceptionHandler)
|
||||
private val pluginsDir = "${paths.getApplicationDataDirPath()}/plugins".toPath()
|
||||
val pluginsDirPath: Path get() = pluginsDir
|
||||
private val httpClient = HttpClient()
|
||||
private val httpClient get() = httpClientFactory.default
|
||||
|
||||
enum class InstallPromptKind {
|
||||
INSTALL,
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
|
||||
package org.spotube.spotube.modules.plugin
|
||||
|
||||
import org.spotube.spotube.core.network.HttpClientFactory
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import org.spotube.spotube.core.di.injectLogger
|
||||
@ -141,10 +142,11 @@ private data class PluginScreenState(
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class PluginViewModel(
|
||||
private val pluginManager: PluginManager,
|
||||
private val webViewController: WebViewController) : ViewModel(), KoinComponent {
|
||||
private val webViewController: WebViewController,
|
||||
httpClientFactory: HttpClientFactory) : ViewModel(), KoinComponent {
|
||||
|
||||
private val logger by injectLogger<PluginViewModel>()
|
||||
private val gitHubRepo = GitHubPluginRepository()
|
||||
private val gitHubRepo = GitHubPluginRepository(httpClientFactory.json)
|
||||
|
||||
private val _screenState = MutableStateFlow(PluginScreenState())
|
||||
private val _allRepos = mutableListOf<GitHubRepo>()
|
||||
@ -658,8 +660,4 @@ class PluginViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
gitHubRepo.close()
|
||||
super.onCleared()
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,6 +17,9 @@
|
||||
|
||||
package org.spotube.spotube.modules.settings
|
||||
|
||||
import org.spotube.spotube.core.server.isContainerPlayableOn
|
||||
import org.spotube.spotube.core.server.currentPlatform
|
||||
import org.spotube.spotube.PlatformType
|
||||
import org.spotube.plugin_interfaces.plugin_apis.audio.AudioFormat
|
||||
import org.spotube.plugin_interfaces.plugin_apis.audio.AudioQuality
|
||||
import kotlinx.serialization.SerialName
|
||||
@ -42,15 +45,7 @@ data class UserSettings(
|
||||
val palette: MillenniumPalette = MillenniumPalette.Blue,
|
||||
|
||||
// Playback
|
||||
val streamingMusicFormat: AudioFormat = AudioFormat(
|
||||
codec = "opus",
|
||||
container = "webm",
|
||||
qualities = listOf(
|
||||
AudioQuality.Lossy(bitrate = 44_000),
|
||||
AudioQuality.Lossy(bitrate = 96_000),
|
||||
AudioQuality.Lossy(bitrate = 128_000),
|
||||
AudioQuality.Lossy(bitrate = 256_000))
|
||||
),
|
||||
val streamingMusicFormat: AudioFormat = defaultStreamingMusicFormat(),
|
||||
val streamingMusicQuality: AudioQuality = AudioQuality.Lossy(bitrate = 256_000),
|
||||
val enableMusicCaching: Boolean = true,
|
||||
val cacheFolder: String? = null,
|
||||
@ -111,3 +106,24 @@ data class JamBroker(
|
||||
val clientIdPrefix: String = "spotube",
|
||||
val keepAliveSeconds: Int = 30,
|
||||
val connectionTimeoutSeconds: Int = 10)
|
||||
|
||||
/**
|
||||
* opus/webm everywhere except iOS, whose AVPlayer can't open WebM/Ogg, so it streams AAC.
|
||||
* TODO: revisit once iOS can play Opus (e.g. remuxing to CAF or a non-AVPlayer backend).
|
||||
*/
|
||||
internal fun defaultStreamingMusicFormat(platform: PlatformType = currentPlatform): AudioFormat {
|
||||
val ios = platform == PlatformType.IOS
|
||||
return AudioFormat(
|
||||
codec = if (ios) "aac" else "opus",
|
||||
container = if (ios) "mp4" else "webm",
|
||||
qualities = listOf(
|
||||
AudioQuality.Lossy(bitrate = 44_000),
|
||||
AudioQuality.Lossy(bitrate = 96_000),
|
||||
AudioQuality.Lossy(bitrate = 128_000),
|
||||
AudioQuality.Lossy(bitrate = 256_000)))
|
||||
}
|
||||
|
||||
/** Moves settings saved with a streaming format [platform] can't play (iOS' old opus/webm default) to the default. */
|
||||
internal fun UserSettings.withPlayableStreamingFormat(platform: PlatformType = currentPlatform): UserSettings =
|
||||
if (isContainerPlayableOn(streamingMusicFormat.container, platform)) this
|
||||
else copy(streamingMusicFormat = defaultStreamingMusicFormat(platform))
|
||||
|
||||
@ -54,7 +54,7 @@ class SettingsRepository(private val database: Database) : UserSettingsSource {
|
||||
override val userSettings: StateFlow<UserSettings> = database.settingsDataStore.data.map { prefs ->
|
||||
val json = prefs[SETTINGS_KEY]
|
||||
if (json != null) {
|
||||
settingsJson.decodeFromString<UserSettings>(json as String)
|
||||
settingsJson.decodeFromString<UserSettings>(json as String).withPlayableStreamingFormat()
|
||||
} else {
|
||||
UserSettings() // Default value
|
||||
}
|
||||
@ -73,6 +73,6 @@ class SettingsRepository(private val database: Database) : UserSettingsSource {
|
||||
suspend fun getCurrentSettings(): UserSettings {
|
||||
val preferences = database.settingsDataStore.data.first()
|
||||
val storedJson = preferences[SETTINGS_KEY] ?: return UserSettings()
|
||||
return settingsJson.decodeFromString<UserSettings>(storedJson)
|
||||
return settingsJson.decodeFromString<UserSettings>(storedJson).withPlayableStreamingFormat()
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,6 +18,8 @@
|
||||
package org.spotube.spotube.modules.settings.sections
|
||||
|
||||
|
||||
import org.spotube.spotube.core.server.isContainerPlayableOn
|
||||
import org.spotube.spotube.core.server.currentPlatform
|
||||
import org.spotube.millennium.composables.MillenniumIcon
|
||||
import org.spotube.millennium.composables.MillenniumSeparator
|
||||
import org.spotube.millennium.theme.MillenniumTheme
|
||||
@ -56,6 +58,7 @@ private val standardLossyQualities = listOf(
|
||||
AudioQuality.Lossy(bitrate = 128_000),
|
||||
AudioQuality.Lossy(bitrate = 256_000))
|
||||
|
||||
// Formats the platform's player can't open aren't offered (iOS: no WebM/Ogg).
|
||||
internal val streamingFormatPresets = listOf(
|
||||
AudioFormat(
|
||||
codec = "opus",
|
||||
@ -68,7 +71,8 @@ internal val streamingFormatPresets = listOf(
|
||||
AudioFormat(
|
||||
codec = "vorbis",
|
||||
container = "ogg",
|
||||
qualities = standardLossyQualities))
|
||||
qualities = standardLossyQualities)
|
||||
).filter { isContainerPlayableOn(it.container, currentPlatform) }
|
||||
|
||||
internal val downloadFormatPresets = listOf(
|
||||
AudioFormat(
|
||||
|
||||
@ -17,13 +17,11 @@
|
||||
|
||||
package org.spotube.spotube.modules.update
|
||||
|
||||
import org.spotube.spotube.core.network.HttpClientFactory
|
||||
import co.touchlab.kermit.Logger
|
||||
import org.spotube.spotube.AppBuildInfo
|
||||
import org.spotube.spotube.modules.settings.SettingsRepository
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.header
|
||||
import io.ktor.http.HttpHeaders
|
||||
@ -37,7 +35,6 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import net.swiftzer.semver.SemVer
|
||||
import org.koin.core.component.KoinComponent
|
||||
|
||||
@ -52,14 +49,11 @@ data class AvailableAppUpdate(
|
||||
* process, independent of any screen's lifecycle.
|
||||
*/
|
||||
class AppUpdateService(
|
||||
private val settingsRepository: SettingsRepository) : KoinComponent, AutoCloseable {
|
||||
private val settingsRepository: SettingsRepository,
|
||||
httpClientFactory: HttpClientFactory) : KoinComponent {
|
||||
private val logger = Logger.withTag("AppUpdateService")
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val httpClient = HttpClient(CIO) {
|
||||
install(ContentNegotiation) {
|
||||
json(Json { ignoreUnknownKeys = true })
|
||||
}
|
||||
}
|
||||
private val httpClient = httpClientFactory.json
|
||||
|
||||
private val _availableUpdate = MutableStateFlow<AvailableAppUpdate?>(null)
|
||||
val availableUpdate: StateFlow<AvailableAppUpdate?> = _availableUpdate.asStateFlow()
|
||||
@ -117,10 +111,6 @@ class AppUpdateService(
|
||||
private fun parseVersion(version: String): SemVer? = runCatching {
|
||||
SemVer.parse(version)
|
||||
}.getOrNull()
|
||||
|
||||
override fun close() {
|
||||
httpClient.close()
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
|
||||
@ -8,6 +8,8 @@ import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||
import org.spotube.spotube.core.audioplayer.QueueEntry
|
||||
import org.spotube.spotube.modules.settings.UserSettings
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.koin.core.context.startKoin
|
||||
import org.koin.core.context.stopKoin
|
||||
@ -130,6 +132,22 @@ class StreamingUrlRepositoryBehaviorTest {
|
||||
assertNull(info)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `waits for an audio plugin that is still loading`() = runTest {
|
||||
val h = Harness()
|
||||
val loader = launch {
|
||||
delay(5_000)
|
||||
h.selectPlugin("pluginA").audioAPI.onGetStreamsByTrack = {
|
||||
listOf(streamedSource("src1", listOf(stream("https://example.com/a.opus"))))
|
||||
}
|
||||
}
|
||||
|
||||
val info = h.repository.resolveStreamInfo(track("t1"))
|
||||
loader.join()
|
||||
|
||||
assertEquals("https://example.com/a.opus", info?.url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns null when the plugin yields no sources`() = runTest {
|
||||
val h = Harness()
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
package org.spotube.spotube.core.server
|
||||
|
||||
import org.spotube.spotube.PlatformType
|
||||
import org.spotube.plugin_interfaces.plugin_apis.audio.AudioFormat
|
||||
import org.spotube.plugin_interfaces.plugin_apis.audio.AudioQuality
|
||||
import org.spotube.plugin_interfaces.plugin_apis.audio.AudioStream
|
||||
@ -13,6 +14,7 @@ import kotlin.test.assertSame
|
||||
* Tests for the stream preference selection used by [StreamingUrlRepository].
|
||||
*
|
||||
* Ordering contract (best first):
|
||||
* 0. Playable by the platform's player (iOS: no WebM/Ogg containers, no DASH).
|
||||
* 1. Fewest codec/container mismatches against the preferred format.
|
||||
* 2. Matching quality family (lossy vs lossless).
|
||||
* 3. Closest bitrate / sample rate.
|
||||
@ -54,7 +56,8 @@ class StreamingUrlRepositoryTest {
|
||||
private fun select(
|
||||
streams: List<AudioStream>,
|
||||
format: AudioFormat = opusWebm(),
|
||||
quality: AudioQuality = AudioQuality.Lossy(256_000)) = selectPreferredAudioStream(streams, format, quality)
|
||||
quality: AudioQuality = AudioQuality.Lossy(256_000),
|
||||
platform: PlatformType = PlatformType.Linux) = selectPreferredAudioStream(streams, format, quality, platform)
|
||||
|
||||
// ---------- empty / single ----------
|
||||
|
||||
@ -403,4 +406,34 @@ class StreamingUrlRepositoryTest {
|
||||
assertEquals("https://www.youtube.com", normalizeManifestUrl("", StreamProtocol.DASH))
|
||||
assertEquals("https://www.youtube.com", normalizeManifestUrl("", StreamProtocol.HLS))
|
||||
}
|
||||
|
||||
// ---------- device playability ----------
|
||||
|
||||
@Test
|
||||
fun `on iOS a playable m4a stream beats the preferred but unplayable opus webm`() {
|
||||
val opus = lossy("opus-160", 160_000)
|
||||
val aac = lossy("aac-128", 128_000, codec = "mp4a.40.2", container = "m4a")
|
||||
assertSame(aac, select(listOf(opus, aac), platform = PlatformType.IOS))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on iOS dash manifests are skipped in favour of progressive streams`() {
|
||||
val dash = lossy("dash", 256_000, codec = "mp4a.40.2", container = "m4a", protocol = StreamProtocol.DASH)
|
||||
val progressive = lossy("aac-128", 128_000, codec = "mp4a.40.2", container = "m4a")
|
||||
assertSame(progressive, select(listOf(dash, progressive), platform = PlatformType.IOS))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on iOS an unplayable stream is still returned when nothing else exists`() {
|
||||
val opus = lossy("opus-160", 160_000)
|
||||
assertSame(opus, select(listOf(opus), platform = PlatformType.IOS))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `off iOS the preferred opus webm stream still wins`() {
|
||||
val opus = lossy("opus-160", 160_000)
|
||||
val aac = lossy("aac-128", 128_000, codec = "mp4a.40.2", container = "m4a")
|
||||
assertSame(opus, select(listOf(opus, aac), platform = PlatformType.Android))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -0,0 +1,53 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.spotube.spotube.modules.settings
|
||||
|
||||
import org.spotube.plugin_interfaces.plugin_apis.audio.AudioFormat
|
||||
import org.spotube.spotube.PlatformType
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertSame
|
||||
|
||||
class StreamingFormatDefaultsTest {
|
||||
|
||||
private val opusWebm = AudioFormat(codec = "opus", container = "webm", qualities = emptyList())
|
||||
|
||||
@Test
|
||||
fun `ios defaults to aac in mp4`() {
|
||||
val format = defaultStreamingMusicFormat(PlatformType.IOS)
|
||||
assertEquals("aac" to "mp4", format.codec to format.container)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `other platforms keep opus in webm`() {
|
||||
val format = defaultStreamingMusicFormat(PlatformType.Android)
|
||||
assertEquals("opus" to "webm", format.codec to format.container)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `saved opus webm is moved to aac on ios`() {
|
||||
val migrated = UserSettings(streamingMusicFormat = opusWebm).withPlayableStreamingFormat(PlatformType.IOS)
|
||||
assertEquals("mp4", migrated.streamingMusicFormat.container)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `saved opus webm is kept off ios`() {
|
||||
val settings = UserSettings(streamingMusicFormat = opusWebm)
|
||||
assertSame(settings, settings.withPlayableStreamingFormat(PlatformType.Linux))
|
||||
}
|
||||
}
|
||||
@ -27,6 +27,7 @@ import androidx.compose.ui.window.ComposeUIViewController
|
||||
import com.yushosei.newpipe.extractor.NewPipe
|
||||
import com.yushosei.newpipe.util.DefaultDownloaderImpl
|
||||
import org.spotube.spotube.core.di.initKoin
|
||||
import org.spotube.spotube.core.webview.SafariUserAgent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@ -35,6 +36,7 @@ import org.koin.core.component.inject
|
||||
|
||||
fun MainViewController() = ComposeUIViewController(configure = {
|
||||
initKoin()
|
||||
SafariUserAgent.prime()
|
||||
}) { InitNewPipe { App() } }
|
||||
|
||||
@Composable
|
||||
|
||||
@ -77,9 +77,9 @@ actual class NewPipeService {
|
||||
.map { stream ->
|
||||
org.spotube.plugin_interfaces.plugin_apis.audio.AudioStream.Lossy(
|
||||
url = stream.content,
|
||||
codec = stream.codec ?: "unknown",
|
||||
codec = normalizeCodec(stream.codec ?: "unknown"),
|
||||
bitrate = stream.bitrate,
|
||||
container = stream.format?.suffix ?: "unknown")
|
||||
container = normalizeContainer(stream.format?.suffix ?: "unknown"))
|
||||
},
|
||||
videoStreams = listOf(), // Not yet supported
|
||||
id = id
|
||||
|
||||
@ -1,41 +1,43 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.spotube.spotube.core.webview
|
||||
|
||||
import dev.nucleusframework.webview.web.WebViewState
|
||||
import kotlinx.cinterop.ExperimentalForeignApi
|
||||
import platform.Foundation.NSDate
|
||||
import platform.Foundation.NSHTTPCookieStorage
|
||||
import platform.Foundation.distantPast
|
||||
import platform.WebKit.WKWebsiteDataStore
|
||||
import platform.WebKit.WKWebsiteDataTypeCookies
|
||||
import platform.WebKit.WKWebsiteDataTypeLocalStorage
|
||||
|
||||
actual fun platformWebviewConfig(webView: WebViewState, pluginId: String?) {
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
actual suspend fun platformClearWebviewData(pluginId: String?) {
|
||||
if (pluginId == null) return
|
||||
|
||||
val dataStore = WKWebsiteDataStore.defaultDataStore()
|
||||
val dataTypes = setOf(WKWebsiteDataTypeCookies, WKWebsiteDataTypeLocalStorage)
|
||||
dataStore.removeDataOfTypes(dataTypes, NSDate.distantPast) {}
|
||||
|
||||
NSHTTPCookieStorage.sharedHTTPCookieStorage.removeCookiesSinceDate(NSDate.distantPast)
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.spotube.spotube.core.webview
|
||||
|
||||
import dev.nucleusframework.webview.web.WebViewState
|
||||
import kotlinx.cinterop.ExperimentalForeignApi
|
||||
import platform.Foundation.NSDate
|
||||
import platform.Foundation.NSHTTPCookieStorage
|
||||
import platform.Foundation.distantPast
|
||||
import platform.WebKit.WKWebsiteDataStore
|
||||
import platform.WebKit.WKWebsiteDataTypeCookies
|
||||
import platform.WebKit.WKWebsiteDataTypeLocalStorage
|
||||
|
||||
actual fun platformWebviewConfig(webView: WebViewState, pluginId: String?) {
|
||||
// Applied to the WKWebView itself, so it covers redirects and XHR too, not just the first request.
|
||||
SafariUserAgent.value?.let { webView.webSettings.customUserAgentString = it }
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
actual suspend fun platformClearWebviewData(pluginId: String?) {
|
||||
if (pluginId == null) return
|
||||
|
||||
val dataStore = WKWebsiteDataStore.defaultDataStore()
|
||||
val dataTypes = setOf(WKWebsiteDataTypeCookies, WKWebsiteDataTypeLocalStorage)
|
||||
dataStore.removeDataOfTypes(dataTypes, NSDate.distantPast) {}
|
||||
|
||||
NSHTTPCookieStorage.sharedHTTPCookieStorage.removeCookiesSinceDate(NSDate.distantPast)
|
||||
}
|
||||
@ -0,0 +1,68 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.spotube.spotube.core.webview
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.cinterop.ExperimentalForeignApi
|
||||
import kotlinx.cinterop.readValue
|
||||
import platform.CoreGraphics.CGRectZero
|
||||
import platform.UIKit.UIDevice
|
||||
import platform.UIKit.UIUserInterfaceIdiomPad
|
||||
import platform.WebKit.WKWebView
|
||||
import platform.WebKit.WKWebViewConfiguration
|
||||
|
||||
/**
|
||||
* Mobile Safari's exact User-Agent on this device.
|
||||
*
|
||||
* WKWebView's default UA ends in `Mobile/15E148` without Safari's `Version/x … Safari/604.1`,
|
||||
* which sites like accounts.spotify.com treat as an embedded browser ("Oops, something went
|
||||
* wrong"). Hand-building the string isn't safe either: Safari freezes the OS part (iOS 26
|
||||
* reports `iPhone OS 18_7`). So WebKit builds it: a probe WKWebView gets Safari's application
|
||||
* name and reports `navigator.userAgent`, which is cached for every web view.
|
||||
*/
|
||||
internal object SafariUserAgent {
|
||||
var value: String? = null
|
||||
private set
|
||||
|
||||
// Kept alive until its async evaluation calls back.
|
||||
private var probe: WKWebView? = null
|
||||
|
||||
/** Must run on the main thread (WebKit); call once at startup. */
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
fun prime() {
|
||||
if (value != null || probe != null) return
|
||||
val config = WKWebViewConfiguration().apply { applicationNameForUserAgent = safariApplicationName() }
|
||||
probe = WKWebView(frame = CGRectZero.readValue(), configuration = config).also { webView ->
|
||||
webView.evaluateJavaScript("navigator.userAgent") { result, _ ->
|
||||
value = result as? String
|
||||
probe = null
|
||||
Logger.withTag("SafariUserAgent").i { "WebView User-Agent: $value" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// What Safari appends after WebKit's base UA; iPad Safari sends the desktop-class form.
|
||||
private fun safariApplicationName(): String {
|
||||
val version = UIDevice.currentDevice.systemVersion.split('.').take(2).joinToString(".")
|
||||
return if (UIDevice.currentDevice.userInterfaceIdiom == UIUserInterfaceIdiomPad) {
|
||||
"Version/$version Safari/605.1.15"
|
||||
} else {
|
||||
"Version/$version Mobile/15E148 Safari/604.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -17,17 +17,32 @@
|
||||
|
||||
package org.spotube.spotube.core.zipline
|
||||
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.newSingleThreadContext
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import platform.Foundation.NSThread
|
||||
import kotlin.coroutines.ContinuationInterceptor
|
||||
|
||||
// Same as JVM/Android. Zipline lets QuickJS recurse up to ~6 MiB of native stack, but
|
||||
// iOS gives secondary threads (incl. newSingleThreadContext's) only 512 KiB, so loading
|
||||
// a plugin overflowed into the guard page and crashed the app with SIGBUS.
|
||||
private const val ZIPLINE_THREAD_STACK_SIZE: ULong = 8_388_608u // 8 MiB
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class, DelicateCoroutinesApi::class)
|
||||
actual fun createZiplineDispatcher(): ZiplineDispatcher {
|
||||
// On iOS/native, thread stack sizes are typically 8 MiB by default,
|
||||
// so newSingleThreadContext is sufficient.
|
||||
val ctx = newSingleThreadContext("Zipline")
|
||||
return ZiplineDispatcher(ctx) {
|
||||
ctx.close()
|
||||
val dispatcher = CompletableDeferred<CoroutineDispatcher>()
|
||||
val stopped = CompletableDeferred<Unit>()
|
||||
// NSThread is the only way to choose a thread's stack size here; it hosts an event
|
||||
// loop (runBlocking) whose dispatcher accepts work from any thread.
|
||||
val thread = NSThread {
|
||||
runBlocking {
|
||||
dispatcher.complete(coroutineContext[ContinuationInterceptor] as CoroutineDispatcher)
|
||||
stopped.await()
|
||||
}
|
||||
}
|
||||
thread.name = "Zipline"
|
||||
thread.stackSize = ZIPLINE_THREAD_STACK_SIZE
|
||||
thread.start()
|
||||
return ZiplineDispatcher(runBlocking { dispatcher.await() }) {
|
||||
stopped.complete(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -28,7 +28,7 @@ appdirs = "1.5.0"
|
||||
cache4k = "0.14.0"
|
||||
carApp = "1.7.0"
|
||||
composeHotReload = "1.2.0"
|
||||
composeMultiplatform = "1.11.1"
|
||||
composeMultiplatform = "1.12.2"
|
||||
coil = "3.5.0"
|
||||
composeShimmer = "1.5.0"
|
||||
desugar_jdk_libs = "2.1.5"
|
||||
@ -37,7 +37,7 @@ jna = "5.18.1"
|
||||
junit = "4.13.2"
|
||||
kermit = "2.1.0"
|
||||
kmpZip = "1.0.0"
|
||||
kotlin = "2.3.21"
|
||||
kotlin = "2.4.21"
|
||||
kotlinx-coroutines = "1.11.0"
|
||||
kotlinx-io = "0.9.1"
|
||||
kotlinx-serialization-json = "1.11.0"
|
||||
@ -52,7 +52,7 @@ nucleusNucleusApplication = "2.5.18"
|
||||
semver = "2.1.0"
|
||||
vlcj = "4.12.1"
|
||||
vlcjNative = "4.12.0"
|
||||
ziplineVersion = "1.27.0"
|
||||
ziplineVersion = "1.28.0"
|
||||
ktor = "3.5.1"
|
||||
dnssdkt = "1.1.0"
|
||||
runner = "1.7.0"
|
||||
@ -68,7 +68,7 @@ feather-icons = "1.1.1"
|
||||
kotlinx-datetime = "0.8.0"
|
||||
media3 = "1.10.1"
|
||||
androidx-datastore = "1.2.1"
|
||||
ksp = "2.3.5"
|
||||
ksp = "2.3.12"
|
||||
spotube-gradle = "0.1.2"
|
||||
cryptography = "0.6.0"
|
||||
reorderable = "3.1.0"
|
||||
@ -76,7 +76,7 @@ vlcjBundler = "0.2.0"
|
||||
vanniktechMavenPublish = "0.37.0"
|
||||
uniffi = "0.3.7"
|
||||
kotlinx-atomicfu = "0.33.0"
|
||||
mokkery = "3.3.0"
|
||||
mokkery = "3.5.1"
|
||||
jmustache = "1.16"
|
||||
|
||||
[libraries]
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import SwiftUI
|
||||
import ComposeApp
|
||||
|
||||
@main
|
||||
struct iOSApp: App {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user