feat: port upstream PRs #3132, #3170, #3150, #3092, #3134, #3147

Co-Authored-By: pgwiz <pgwiz@users.noreply.github.com>
This commit is contained in:
pgwiz 2026-10-28 07:36:57 +03:00
parent 2e84018155
commit 2a2ca8c5a6
13 changed files with 452 additions and 18 deletions

View File

@ -2,7 +2,17 @@
## [Unreleased] ## [Unreleased]
### Features
- **audioplayer/ios**: Implement `IosMediaSession` with `AVAudioSession`, `MPNowPlayingInfoCenter`, and `MPRemoteCommandCenter` for lock-screen / Control Center media controls and background audio playback (PR #3170).
- **audioplayer/ios**: Enable direct streaming resolution for iOS playback in `DeviceAudioPlayerQueue.toMediaItem` (PR #3170).
- **audioplayer/DeviceAudioPlayerQueue**: Implement complete 4-tier endless playback recommendation cascade: metadata recommendations, native YouTube radio mix, album tracks, and artist top tracks (PR #3092, PR #3134).
- **plugin/PluginManager**: Auto-select vacant ability slots on plugin installation and recover fallback providers on plugin removal (PR #3147).
### Fixes ### Fixes
- **build/android**: Switch `desugar_jdk_libs` to `com.android.tools:desugar_jdk_libs_nio` to resolve Android 9-12 `NoSuchMethodError: java.net.URLEncoder.encode` (PR #3132).
- **server/StreamProxy**: Harden YouTube/GoogleVideo proxy streaming against HTTP 403 Forbidden by inspecting client parameter `c` (`ANDROID`, `IOS`, `VISIONOS`), injecting matching mobile User-Agents, and stripping `Origin` and `Referer` on mobile streams (PR #3150).
- **downloads/DownloadManager**: Prevent HTTP 403 Forbidden on YouTube audio downloads by injecting matching client User-Agents and using `Range: bytes=0-0` byte-range probes instead of bare `HEAD` requests (PR #3150).
- **server/StreamingUrlRepository**: Evict stale cached track sources from `MatchedTracksRepository` when cached source resolution fails before falling back to fresh track lookups (PR #3092, PR #3134).
- **server/StreamProxy**: Prevent GoogleVideo CDN HTTP 403 Forbidden by injecting `Referer: https://www.youtube.com/`, `Origin: https://www.youtube.com`, and browser `User-Agent` headers on `googlevideo.com`, `youtube.com`, and `youtu.be` proxy requests, preserving stream range headers (`Range`, `If-Range`) without header duplication, and filtering hop-by-hop headers. - **server/StreamProxy**: Prevent GoogleVideo CDN HTTP 403 Forbidden by injecting `Referer: https://www.youtube.com/`, `Origin: https://www.youtube.com`, and browser `User-Agent` headers on `googlevideo.com`, `youtube.com`, and `youtu.be` proxy requests, preserving stream range headers (`Range`, `If-Range`) without header duplication, and filtering hop-by-hop headers.
- **newpipe/RealNewPipeAudioAPI**: Fix YouTube video ID extraction when `track.externalUri` contains YouTube URLs (`v=`, `youtu.be`, `/embed/`, `/shorts/`, with trailing slash/query/fragment support) instead of passing raw Spotify track IDs to video info lookups. - **newpipe/RealNewPipeAudioAPI**: Fix YouTube video ID extraction when `track.externalUri` contains YouTube URLs (`v=`, `youtu.be`, `/embed/`, `/shorts/`, with trailing slash/query/fragment support) instead of passing raw Spotify track IDs to video info lookups.
- **newpipe/RealNewPipeAudioAPI**: Improve track matching and scoring for Topic channels, VEVO uploads, and official variants with relaxed duration bins, and guarantee top relevance search result is prioritized at index 0 when confidence scoring yields no candidates $\ge 0.3$. - **newpipe/RealNewPipeAudioAPI**: Improve track matching and scoring for Topic channels, VEVO uploads, and official variants with relaxed duration bins, and guarantee top relevance search result is prioritized at index 0 when confidence scoring yields no candidates $\ge 0.3$.

View File

@ -42,6 +42,10 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.spotube.spotube.PlatformType
import org.spotube.spotube.core.server.StreamingUrlRepository
import org.spotube.spotube.getPlatform
import kotlin.random.Random import kotlin.random.Random
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
@ -53,6 +57,7 @@ class DeviceAudioPlayerQueue(
private val blacklistRepository: BlacklistRepository) : AudioPlayerQueue, KoinComponent { private val blacklistRepository: BlacklistRepository) : AudioPlayerQueue, KoinComponent {
private val logger by injectLogger<DeviceAudioPlayerQueue>() private val logger by injectLogger<DeviceAudioPlayerQueue>()
private val streamingUrlRepository by inject<StreamingUrlRepository>()
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@ -401,9 +406,48 @@ class DeviceAudioPlayerQueue(
} }
} }
if (recommendations.isEmpty() && metadataService != null) {
val albumId = seedTracks.mapNotNull { it.track.album?.id }.firstOrNull { it.isNotBlank() }
if (!albumId.isNullOrBlank()) {
logger.i { "Endless playback: radio mix unavailable or empty, falling back to album tracks for album $albumId" }
val albumTracks = runCatching {
metadataService.use {
metadataAlbumAPI.getAlbumTracks(albumId).items
}
}.getOrDefault(emptyList())
val existingTrackIds = queue.filterIsInstance<QueueEntry.StreamingTrack>().map { it.track.id }.toSet()
val newAlbumTracks = albumTracks.filter { it.id !in existingTrackIds }
if (newAlbumTracks.isNotEmpty()) {
logger.i { "Endless playback: got ${newAlbumTracks.size} remaining album tracks for album $albumId" }
recommendations = newAlbumTracks
}
}
}
if (recommendations.isEmpty() && metadataService != null) {
logger.i { "Endless playback: album tracks unavailable or empty, falling back to artist top tracks" }
val existingTrackIds = queue.filterIsInstance<QueueEntry.StreamingTrack>().map { it.track.id }.toSet()
val artistIds = seedTracks.flatMap { it.track.artists }.map { it.id }.filter { it.isNotBlank() }.distinct()
for (artistId in artistIds) {
val topTracks = runCatching {
metadataService.use {
metadataArtistAPI.getArtistTop10Tracks(artistId)
}
}.getOrDefault(emptyList())
val newTracks = topTracks.filter { it.id !in existingTrackIds }
if (newTracks.isNotEmpty()) {
logger.i { "Endless playback: got ${newTracks.size} top tracks for artist $artistId" }
recommendations = newTracks
break
}
}
}
try { try {
if (recommendations.isEmpty()) { if (recommendations.isEmpty()) {
logger.w { "Endless playback: no recommendations or radio mix tracks found" } logger.w { "Endless playback: no recommendations, radio mix, album tracks, or artist top tracks found" }
isFetchingRecommendations = false isFetchingRecommendations = false
return return
} }
@ -517,7 +561,25 @@ class DeviceAudioPlayerQueue(
private suspend fun QueueEntry.toMediaItem(): MediaItem? { private suspend fun QueueEntry.toMediaItem(): MediaItem? {
return when (this) { return when (this) {
is QueueEntry.StreamingTrack -> { is QueueEntry.StreamingTrack -> {
val resolvedUrl = url.ifBlank { buildStreamingUrl(track.id, protocol) } val isIos = getPlatform().type == PlatformType.IOS
val requiresIosResolution = isIos && (
url.isBlank() ||
url.startsWith("http://127.0.0.1") ||
url.startsWith("https://127.0.0.1") ||
url.startsWith("http://[::1]") ||
url.startsWith("https://[::1]") ||
url.startsWith("http://localhost") ||
url.startsWith("https://localhost"))
val resolvedStream = if (requiresIosResolution) {
streamingUrlRepository.resolveStreamInfo(track)
} else {
null
}
val resolvedUrl = if (requiresIosResolution) {
resolvedStream?.url ?: buildStreamingUrl(track.id, protocol)
} else {
url.ifBlank { buildStreamingUrl(track.id, protocol) }
}
if (resolvedUrl.isBlank()) return null if (resolvedUrl.isBlank()) return null
MediaItem( MediaItem(
title = track.title, title = track.title,
@ -526,7 +588,7 @@ class DeviceAudioPlayerQueue(
duration = track.durationMs.milliseconds, duration = track.durationMs.milliseconds,
coverURL = artworkUrl.orEmpty(), coverURL = artworkUrl.orEmpty(),
url = resolvedUrl, url = resolvedUrl,
protocol = protocol) protocol = resolvedStream?.protocol ?: protocol)
} }
is QueueEntry.LocalTrack -> { is QueueEntry.LocalTrack -> {

View File

@ -43,6 +43,7 @@ data class PluginTaggedAudioSource(
interface TrackSourceRepository { interface TrackSourceRepository {
suspend fun getTrackSource(track: MetadataTrack, pluginId: String): AudioSource.Basic? suspend fun getTrackSource(track: MetadataTrack, pluginId: String): AudioSource.Basic?
suspend fun saveTrackSource(track: MetadataTrack, source: AudioSource.Basic, pluginId: String) suspend fun saveTrackSource(track: MetadataTrack, source: AudioSource.Basic, pluginId: String)
suspend fun deleteTrackSource(track: MetadataTrack, pluginId: String)
} }
class MatchedTracksRepository(private val database: Database) : TrackSourceRepository { class MatchedTracksRepository(private val database: Database) : TrackSourceRepository {
@ -72,4 +73,19 @@ class MatchedTracksRepository(private val database: Database) : TrackSourceRepos
Json.encodeToString(PluginTaggedAudioSource(pluginId, source)) Json.encodeToString(PluginTaggedAudioSource(pluginId, source))
} }
} }
override suspend fun deleteTrackSource(track: MetadataTrack, pluginId: String) {
database.matchedTracksDataStore.edit { prefs ->
val key = stringPreferencesKey(track.id)
val json = prefs[key] ?: return@edit
val tagged = try {
Json.decodeFromString<PluginTaggedAudioSource>(json)
} catch (_: Exception) {
null
}
if (tagged == null || tagged.pluginId == pluginId) {
prefs.remove(key)
}
}
}
} }

View File

@ -76,6 +76,12 @@ internal class StreamProxy(
private const val YOUTUBE_REFERER = "https://www.youtube.com/" private const val YOUTUBE_REFERER = "https://www.youtube.com/"
private const val BROWSER_USER_AGENT = 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" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
private const val ANDROID_USER_AGENT =
"com.google.android.youtube/20.10.38 (Linux; U; Android 11) gzip"
private const val IOS_USER_AGENT =
"com.google.ios.youtube/20.10.4 (iPhone16,2; U; CPU iOS 18_3_2 like Mac OS X;)"
private const val VISIONOS_USER_AGENT =
"com.google.visionos.youtube/1.04(RealityDevice17,1; U; CPU visionOS 26_6_0 like Mac OS X; US)"
} }
private val fileSystem = FileSystem.SYSTEM private val fileSystem = FileSystem.SYSTEM
@ -599,9 +605,31 @@ internal class StreamProxy(
call.forwardRequestHeaderIfPresent(builder.headers, HttpHeaders.Accept) call.forwardRequestHeaderIfPresent(builder.headers, HttpHeaders.Accept)
if (isYouTubeOrGoogleVideo(targetUrl)) { if (isYouTubeOrGoogleVideo(targetUrl)) {
builder.headers[HttpHeaders.Referrer] = YOUTUBE_REFERER val clientParam = runCatching { Url(targetUrl).parameters["c"]?.uppercase() }.getOrNull()
builder.headers[HttpHeaders.Origin] = "https://www.youtube.com" ?: Regex("""[?&]c=([^&#]+)""").find(targetUrl)?.groupValues?.get(1)?.uppercase()
builder.headers[HttpHeaders.UserAgent] = BROWSER_USER_AGENT
when (clientParam) {
"ANDROID" -> {
builder.headers[HttpHeaders.UserAgent] = ANDROID_USER_AGENT
builder.headers.remove(HttpHeaders.Referrer)
builder.headers.remove(HttpHeaders.Origin)
}
"IOS" -> {
builder.headers[HttpHeaders.UserAgent] = IOS_USER_AGENT
builder.headers.remove(HttpHeaders.Referrer)
builder.headers.remove(HttpHeaders.Origin)
}
"VISIONOS" -> {
builder.headers[HttpHeaders.UserAgent] = VISIONOS_USER_AGENT
builder.headers.remove(HttpHeaders.Referrer)
builder.headers.remove(HttpHeaders.Origin)
}
else -> {
builder.headers[HttpHeaders.Referrer] = YOUTUBE_REFERER
builder.headers[HttpHeaders.Origin] = "https://www.youtube.com"
builder.headers[HttpHeaders.UserAgent] = BROWSER_USER_AGENT
}
}
} else { } else {
call.forwardRequestHeaderIfPresent(builder.headers, HttpHeaders.UserAgent) call.forwardRequestHeaderIfPresent(builder.headers, HttpHeaders.UserAgent)
call.forwardRequestHeaderIfPresent(builder.headers, HttpHeaders.Referrer) call.forwardRequestHeaderIfPresent(builder.headers, HttpHeaders.Referrer)

View File

@ -186,6 +186,7 @@ class StreamingUrlRepository(
} }
logger.d { "Cached source did not provide a stream for track $trackId; falling back to track lookup" } logger.d { "Cached source did not provide a stream for track $trackId; falling back to track lookup" }
matchedTracksRepository.deleteTrackSource(track, audioPlugin.pluginId)
} }
logger.d { "Attempting stream resolution for track $trackId using track lookup" } logger.d { "Attempting stream resolution for track $trackId using track lookup" }

View File

@ -34,8 +34,10 @@ 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.HttpStatusCode
import io.ktor.http.Url
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.discard
import io.ktor.utils.io.readRemaining import io.ktor.utils.io.readRemaining
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -95,6 +97,15 @@ class DownloadManager(
const val MAX_CONCURRENT_DOWNLOADS = 4 const val MAX_CONCURRENT_DOWNLOADS = 4
private const val SEGMENT_COUNT = 4 private const val SEGMENT_COUNT = 4
private const val CHUNK_SIZE = 256 * 1024 private const val CHUNK_SIZE = 256 * 1024
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 const val ANDROID_USER_AGENT =
"com.google.android.youtube/20.10.38 (Linux; U; Android 11) gzip"
private const val IOS_USER_AGENT =
"com.google.ios.youtube/20.10.4 (iPhone16,2; U; CPU iOS 18_3_2 like Mac OS X;)"
private const val VISIONOS_USER_AGENT =
"com.google.visionos.youtube/1.04(RealityDevice17,1; U; CPU visionOS 26_6_0 like Mac OS X; US)"
} }
private val logger = Logger.withTag("DownloadManager") private val logger = Logger.withTag("DownloadManager")
@ -362,6 +373,7 @@ class DownloadManager(
headers { headers {
append(HttpHeaders.Range, "bytes=${segment.start}-${segment.end}") append(HttpHeaders.Range, "bytes=${segment.start}-${segment.end}")
} }
applyClientHeaders(url)
} }
logger.d { logger.d {
"[Download ${item.id}] segment ${segment.index} bytes=${segment.start}-${segment.end} -> " + "[Download ${item.id}] segment ${segment.index} bytes=${segment.start}-${segment.end} -> " +
@ -398,7 +410,9 @@ class DownloadManager(
downloadDir: Path) { downloadDir: Path) {
val tempFile = tempDir / "${item.id}.tmp" val tempFile = tempDir / "${item.id}.tmp"
val response = httpClient.get(url) val response = httpClient.get(url) {
applyClientHeaders(url)
}
val totalSize = response.contentLength() ?: 0L val totalSize = response.contentLength() ?: 0L
logger.i { logger.i {
"[Download ${item.id}] GET -> HTTP ${response.status.value} " + "[Download ${item.id}] GET -> HTTP ${response.status.value} " +
@ -442,21 +456,30 @@ class DownloadManager(
private suspend fun probeUrl(id: String, url: String): Pair<Long, Boolean> { private suspend fun probeUrl(id: String, url: String): Pair<Long, Boolean> {
return try { return try {
val response = httpClient.head(url) val response = httpClient.get(url) {
val contentLength = response.contentLength() ?: 0L headers {
append(HttpHeaders.Range, "bytes=0-0")
}
applyClientHeaders(url)
}
val contentRange = response.headers[HttpHeaders.ContentRange]
val totalFromContentRange = contentRange?.substringAfterLast('/')?.trim()?.toLongOrNull()
val contentLength = totalFromContentRange ?: response.contentLength() ?: 0L
val acceptRanges = response.headers[HttpHeaders.AcceptRanges] val acceptRanges = response.headers[HttpHeaders.AcceptRanges]
val supportsRanges = acceptRanges?.equals("bytes", ignoreCase = true) == true val supportsRanges = response.status == HttpStatusCode.PartialContent ||
acceptRanges?.equals("bytes", ignoreCase = true) == true
val contentType = response.headers[HttpHeaders.ContentType] val contentType = response.headers[HttpHeaders.ContentType]
logger.i { logger.i {
"[Download $id] HEAD -> HTTP ${response.status.value} type=$contentType " + "[Download $id] PROBE (bytes=0-0) -> HTTP ${response.status.value} type=$contentType " +
"length=$contentLength acceptRanges=$acceptRanges" "length=$contentLength contentRange=$contentRange acceptRanges=$acceptRanges"
} }
runCatching { response.bodyAsChannel().discard() }
rejectVideo(id, contentType) rejectVideo(id, contentType)
Pair(contentLength, supportsRanges) Pair(contentLength, supportsRanges)
} catch (e: DownloadException) { } catch (e: DownloadException) {
throw e 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] PROBE request failed, falling back to single connection" }
Pair(0L, false) Pair(0L, false)
} }
} }
@ -472,6 +495,46 @@ class DownloadManager(
} }
} }
private fun isYouTubeOrGoogleVideo(url: String): Boolean {
val host = runCatching { Url(url).host.lowercase() }.getOrNull() ?: ""
return host.endsWith("googlevideo.com") ||
host.endsWith("youtube.com") ||
host.endsWith("youtu.be") ||
url.contains("googlevideo.com", ignoreCase = true) ||
url.contains("youtube.com", ignoreCase = true) ||
url.contains("youtu.be", ignoreCase = true)
}
private fun io.ktor.client.request.HttpRequestBuilder.applyClientHeaders(targetUrl: String) {
if (isYouTubeOrGoogleVideo(targetUrl)) {
val clientParam = runCatching { Url(targetUrl).parameters["c"]?.uppercase() }.getOrNull()
?: Regex("""[?&]c=([^&#]+)""").find(targetUrl)?.groupValues?.get(1)?.uppercase()
when (clientParam) {
"ANDROID" -> {
headers[HttpHeaders.UserAgent] = ANDROID_USER_AGENT
headers.remove(HttpHeaders.Referrer)
headers.remove(HttpHeaders.Origin)
}
"IOS" -> {
headers[HttpHeaders.UserAgent] = IOS_USER_AGENT
headers.remove(HttpHeaders.Referrer)
headers.remove(HttpHeaders.Origin)
}
"VISIONOS" -> {
headers[HttpHeaders.UserAgent] = VISIONOS_USER_AGENT
headers.remove(HttpHeaders.Referrer)
headers.remove(HttpHeaders.Origin)
}
else -> {
headers[HttpHeaders.Referrer] = YOUTUBE_REFERER
headers[HttpHeaders.Origin] = "https://www.youtube.com"
headers[HttpHeaders.UserAgent] = BROWSER_USER_AGENT
}
}
}
}
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

View File

@ -579,7 +579,12 @@ class PluginManager(
val updatedSelectedPlugins = val updatedSelectedPlugins =
currentState.selectedPlugins.mapValues { (_, selectedPlugin) -> currentState.selectedPlugins.mapValues { (_, selectedPlugin) ->
if (selectedPlugin.id == plugin.id) plugin else selectedPlugin if (selectedPlugin.id == plugin.id) plugin else selectedPlugin
}.toMutableMap()
for (ability in plugin.abilities) {
if (updatedSelectedPlugins[ability] == null) {
updatedSelectedPlugins[ability] = plugin
} }
}
val newState = PluginManagerStates.Data( val newState = PluginManagerStates.Data(
plugins = updatedPlugins, plugins = updatedPlugins,
selectedPlugins = updatedSelectedPlugins, selectedPlugins = updatedSelectedPlugins,
@ -599,7 +604,15 @@ class PluginManager(
if (currentState is PluginManagerStates.Data) { if (currentState is PluginManagerStates.Data) {
val updatedPlugins = currentState.plugins.filterNot { it.id == plugin.id } val updatedPlugins = currentState.plugins.filterNot { it.id == plugin.id }
val updatedSelectedPlugins = val updatedSelectedPlugins =
currentState.selectedPlugins.filterValues { it.id != plugin.id } currentState.selectedPlugins.filterValues { it.id != plugin.id }.toMutableMap()
for (ability in plugin.abilities) {
if (updatedSelectedPlugins[ability] == null) {
val fallback = updatedPlugins.firstOrNull { ability in it.abilities }
if (fallback != null) {
updatedSelectedPlugins[ability] = fallback
}
}
}
val newState = PluginManagerStates.Data( val newState = PluginManagerStates.Data(
plugins = updatedPlugins, plugins = updatedPlugins,
selectedPlugins = updatedSelectedPlugins, selectedPlugins = updatedSelectedPlugins,

View File

@ -447,6 +447,23 @@ class StreamingUrlRepositoryBehaviorTest {
assertEquals("https://example.com/fresh.opus", info?.url) assertEquals("https://example.com/fresh.opus", info?.url)
assertEquals(1, plugin.audioAPI.getStreamsOfAudioSourceCallCount) assertEquals(1, plugin.audioAPI.getStreamsOfAudioSourceCallCount)
assertEquals(1, plugin.audioAPI.getStreamsByTrackCallCount) assertEquals(1, plugin.audioAPI.getStreamsByTrackCallCount)
assertEquals(1, h.matchedTracksRepository.deleteCallCount)
}
@Test
fun `deletes cached track source when cached source fails to provide a stream`() = runTest {
val h = Harness()
val t = track("t1")
val plugin = h.selectPlugin("pluginA")
h.matchedTracksRepository.seed(t, basicSource("stale-1"), pluginId = "pluginA")
plugin.audioAPI.onGetStreamsOfAudioSource = { emptyList() }
plugin.audioAPI.onGetStreamsByTrack = { emptyList() }
val info = h.repository.resolveStreamInfo(t)
assertNull(info)
assertEquals(1, h.matchedTracksRepository.deleteCallCount)
assertNull(h.matchedTracksRepository.rawEntryFor(t.id))
} }
@Test @Test

View File

@ -68,6 +68,7 @@ class FakeTrackSourceRepository : TrackSourceRepository {
private val store = mutableMapOf<String, PluginTaggedAudioSource>() private val store = mutableMapOf<String, PluginTaggedAudioSource>()
var getCallCount = 0 var getCallCount = 0
var saveCallCount = 0 var saveCallCount = 0
var deleteCallCount = 0
override suspend fun getTrackSource(track: MetadataTrack, pluginId: String): AudioSource.Basic? { override suspend fun getTrackSource(track: MetadataTrack, pluginId: String): AudioSource.Basic? {
getCallCount++ getCallCount++
@ -81,6 +82,14 @@ class FakeTrackSourceRepository : TrackSourceRepository {
store[track.id] = PluginTaggedAudioSource(pluginId, source) store[track.id] = PluginTaggedAudioSource(pluginId, source)
} }
override suspend fun deleteTrackSource(track: MetadataTrack, pluginId: String) {
deleteCallCount++
val tagged = store[track.id] ?: return
if (tagged.pluginId == pluginId) {
store.remove(track.id)
}
}
fun rawEntryFor(trackId: String): PluginTaggedAudioSource? = store[trackId] fun rawEntryFor(trackId: String): PluginTaggedAudioSource? = store[trackId]
fun seed(track: MetadataTrack, source: AudioSource.Basic, pluginId: String) { fun seed(track: MetadataTrack, source: AudioSource.Basic, pluginId: String) {

View File

@ -62,6 +62,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
private val avPlayer: AVPlayer = AVPlayer() private val avPlayer: AVPlayer = AVPlayer()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val mediaSession = IosMediaSession()
private var disposed = false private var disposed = false
@ -97,6 +98,13 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
private var lastTimeControlStatus: AVPlayerTimeControlStatus? = null private var lastTimeControlStatus: AVPlayerTimeControlStatus? = null
init { init {
mediaSession.configure(
onPlay = { scope.launch { play() } },
onPause = { scope.launch { pause() } },
onSeek = { seconds -> scope.launch { seekTo(seconds.seconds) } },
onNext = { scope.launch { skipToNext() } },
onPrevious = { scope.launch { skipToPrevious() } })
avPlayer.addPeriodicTimeObserverForInterval( avPlayer.addPeriodicTimeObserverForInterval(
interval = CMTimeMake(1, 4), interval = CMTimeMake(1, 4),
queue = null queue = null
@ -115,6 +123,11 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
// val range = loadedRanges.objectAtIndex(0u) // val range = loadedRanges.objectAtIndex(0u)
// } // }
} }
mediaSession.update(
item = _currentMediaItem.value,
positionSeconds = seconds,
durationSeconds = _duration.value.inWholeMilliseconds / 1000.0,
isPlaying = avPlayer.timeControlStatus == AVPlayerTimeControlStatusPlaying)
} }
} }
@ -159,6 +172,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
} }
actual override suspend fun play() { actual override suspend fun play() {
mediaSession.activate()
avPlayer.play() avPlayer.play()
} }
@ -170,6 +184,12 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
avPlayer.pause() avPlayer.pause()
avPlayer.seekToTime(CMTimeMake(0, 1)) avPlayer.seekToTime(CMTimeMake(0, 1))
_playerState.tryEmit(PlayerState.IDLE) _playerState.tryEmit(PlayerState.IDLE)
mediaSession.deactivate()
mediaSession.update(
item = _currentMediaItem.value,
positionSeconds = 0.0,
durationSeconds = _duration.value.inWholeMilliseconds / 1000.0,
isPlaying = false)
} }
actual override suspend fun seekTo(position: Duration) { actual override suspend fun seekTo(position: Duration) {
@ -220,9 +240,14 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
avPlayer.replaceCurrentItemWithPlayerItem(avItem) avPlayer.replaceCurrentItemWithPlayerItem(avItem)
_currentMediaItem.tryEmit(item) _currentMediaItem.tryEmit(item)
mediaSession.update(
item = item,
positionSeconds = 0.0,
durationSeconds = item.duration.inWholeMilliseconds / 1000.0,
isPlaying = false)
if (autoPlay) { if (autoPlay) {
avPlayer.play() play()
} }
} }
@ -277,7 +302,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
if (avItem != null) { if (avItem != null) {
avPlayer.replaceCurrentItemWithPlayerItem(avItem) avPlayer.replaceCurrentItemWithPlayerItem(avItem)
_currentMediaItem.tryEmit(nextItem) _currentMediaItem.tryEmit(nextItem)
avPlayer.play() play()
} }
} }
} }
@ -293,7 +318,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
if (avItem != null) { if (avItem != null) {
avPlayer.replaceCurrentItemWithPlayerItem(avItem) avPlayer.replaceCurrentItemWithPlayerItem(avItem)
_currentMediaItem.tryEmit(prevItem) _currentMediaItem.tryEmit(prevItem)
avPlayer.play() play()
} }
} }
} }
@ -327,6 +352,8 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
disposed = true disposed = true
avPlayer.pause() avPlayer.pause()
avPlayer.replaceCurrentItemWithPlayerItem(null) avPlayer.replaceCurrentItemWithPlayerItem(null)
mediaSession.deactivate()
mediaSession.clear()
currentPlaylist.clear() currentPlaylist.clear()
urlIndexMap.clear() urlIndexMap.clear()

View File

@ -0,0 +1,184 @@
/*
* 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/>.
*/
@file:OptIn(ExperimentalForeignApi::class)
package org.spotube.spotube.core.audioplayer
import kotlinx.cinterop.ExperimentalForeignApi
import platform.AVFAudio.AVAudioSession
import platform.AVFAudio.AVAudioSessionCategoryPlayback
import platform.AVFAudio.AVAudioSessionModeDefault
import platform.Foundation.NSDictionary
import platform.Foundation.NSMutableDictionary
import platform.Foundation.NSNotificationCenter
import platform.Foundation.NSOperationQueue
import platform.MediaPlayer.MPChangePlaybackPositionCommandEvent
import platform.MediaPlayer.MPMediaItemPropertyAlbumTitle
import platform.MediaPlayer.MPMediaItemPropertyArtist
import platform.MediaPlayer.MPMediaItemPropertyPlaybackDuration
import platform.MediaPlayer.MPMediaItemPropertyTitle
import platform.MediaPlayer.MPNowPlayingInfoCenter
import platform.MediaPlayer.MPNowPlayingInfoPropertyElapsedPlaybackTime
import platform.MediaPlayer.MPNowPlayingInfoPropertyPlaybackRate
import platform.MediaPlayer.MPRemoteCommand
import platform.MediaPlayer.MPRemoteCommandCenter
import platform.MediaPlayer.MPRemoteCommandHandlerStatusCommandFailed
import platform.MediaPlayer.MPRemoteCommandHandlerStatusSuccess
internal class IosMediaSession {
private val audioSession = AVAudioSession.sharedInstance()
private val nowPlayingInfoCenter = MPNowPlayingInfoCenter.defaultCenter()
private val remoteCommandCenter = MPRemoteCommandCenter.sharedCommandCenter()
private var configured = false
private val notificationCenter = NSNotificationCenter.defaultCenter()
private val notificationTokens = mutableListOf<Any>()
private val remoteCommandTokens = mutableListOf<Pair<MPRemoteCommand, Any>>()
private var onPlay: (() -> Unit)? = null
private var onPause: (() -> Unit)? = null
private var onSeek: ((Double) -> Unit)? = null
private var onNext: (() -> Unit)? = null
private var onPrevious: (() -> Unit)? = null
fun configure(
onPlay: () -> Unit,
onPause: () -> Unit,
onSeek: (Double) -> Unit,
onNext: () -> Unit,
onPrevious: () -> Unit
) {
this.onPlay = onPlay
this.onPause = onPause
this.onSeek = onSeek
this.onNext = onNext
this.onPrevious = onPrevious
if (configured) return
configured = true
audioSession.setCategory(
category = AVAudioSessionCategoryPlayback,
mode = AVAudioSessionModeDefault,
options = 0u,
error = null)
registerAudioSessionNotifications()
registerRemoteCommands()
}
fun activate() {
audioSession.setActive(true, error = null)
}
fun deactivate() {
audioSession.setActive(false, error = null)
}
fun update(
item: MediaItem?,
positionSeconds: Double,
durationSeconds: Double,
isPlaying: Boolean
) {
if (item == null) {
nowPlayingInfoCenter.nowPlayingInfo = null
return
}
val info = NSMutableDictionary()
info.setObject(item.title, forKey = MPMediaItemPropertyTitle)
info.setObject(item.artist, forKey = MPMediaItemPropertyArtist)
item.album.takeIf { it.isNotBlank() }?.let {
info.setObject(it, forKey = MPMediaItemPropertyAlbumTitle)
}
if (durationSeconds.isFinite() && durationSeconds > 0) {
info.setObject(durationSeconds, forKey = MPMediaItemPropertyPlaybackDuration)
}
if (positionSeconds.isFinite() && positionSeconds >= 0) {
info.setObject(positionSeconds, forKey = MPNowPlayingInfoPropertyElapsedPlaybackTime)
}
info.setObject(if (isPlaying) 1.0 else 0.0, forKey = MPNowPlayingInfoPropertyPlaybackRate)
nowPlayingInfoCenter.nowPlayingInfo = info as NSDictionary
}
fun clear() {
nowPlayingInfoCenter.nowPlayingInfo = null
notificationTokens.forEach { notificationCenter.removeObserver(it) }
notificationTokens.clear()
remoteCommandTokens.forEach { (command, token) -> command.removeTarget(token) }
remoteCommandTokens.clear()
configured = false
onPlay = null
onPause = null
onSeek = null
onNext = null
onPrevious = null
}
private fun registerAudioSessionNotifications() {
notificationTokens += notificationCenter.addObserverForName(
name = platform.AVFAudio.AVAudioSessionInterruptionNotification,
`object` = audioSession,
queue = NSOperationQueue.mainQueue()) { notification ->
val type = notification?.userInfo?.get(
platform.AVFAudio.AVAudioSessionInterruptionTypeKey) as? Number
if (type?.intValue == platform.AVFAudio.AVAudioSessionInterruptionTypeBegan) {
onPause?.invoke()
} else if (type?.intValue == platform.AVFAudio.AVAudioSessionInterruptionTypeEnded) {
activate()
val options = notification?.userInfo?.get(
platform.AVFAudio.AVAudioSessionInterruptionOptionKey) as? Number
val shouldResume = (options?.intValue ?: 0) and
platform.AVFAudio.AVAudioSessionInterruptionOptionShouldResume.toInt() != 0
if (shouldResume) onPlay?.invoke()
}
}
}
private fun registerRemoteCommands() {
remoteCommandTokens += remoteCommandCenter.playCommand to
remoteCommandCenter.playCommand.addTargetWithHandler {
onPlay?.invoke()
MPRemoteCommandHandlerStatusSuccess
}
remoteCommandTokens += remoteCommandCenter.pauseCommand to
remoteCommandCenter.pauseCommand.addTargetWithHandler {
onPause?.invoke()
MPRemoteCommandHandlerStatusSuccess
}
remoteCommandTokens += remoteCommandCenter.changePlaybackPositionCommand to
remoteCommandCenter.changePlaybackPositionCommand.addTargetWithHandler { event ->
val position = (event as? MPChangePlaybackPositionCommandEvent)?.positionTime
if (position != null && position.isFinite() && position >= 0) {
onSeek?.invoke(position)
MPRemoteCommandHandlerStatusSuccess
} else {
MPRemoteCommandHandlerStatusCommandFailed
}
}
remoteCommandTokens += remoteCommandCenter.nextTrackCommand to
remoteCommandCenter.nextTrackCommand.addTargetWithHandler {
onNext?.invoke()
MPRemoteCommandHandlerStatusSuccess
}
remoteCommandTokens += remoteCommandCenter.previousTrackCommand to
remoteCommandCenter.previousTrackCommand.addTargetWithHandler {
onPrevious?.invoke()
MPRemoteCommandHandlerStatusSuccess
}
}
}

View File

@ -86,7 +86,7 @@ appdirs = { module = "net.harawata:appdirs", version.ref = "appdirs" }
cache4k = { module = "io.github.reactivecircus.cache4k:cache4k", version.ref = "cache4k" } cache4k = { module = "io.github.reactivecircus.cache4k:cache4k", version.ref = "cache4k" }
androidx-car-app = { module = "androidx.car.app:app", version.ref = "carApp" } androidx-car-app = { module = "androidx.car.app:app", version.ref = "carApp" }
compose-shimmer = { module = "com.valentinilk.shimmer:compose-shimmer", version.ref = "composeShimmer" } compose-shimmer = { module = "com.valentinilk.shimmer:compose-shimmer", version.ref = "composeShimmer" }
desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" } desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs_nio", version.ref = "desugar_jdk_libs" }
dns-sd-kt = { module = "com.appstractive:dns-sd-kt", version.ref = "dnssdkt" } dns-sd-kt = { module = "com.appstractive:dns-sd-kt", version.ref = "dnssdkt" }
haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" } haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" }
haze-materials = { module = "dev.chrisbanes.haze:haze-blur-materials", version.ref = "haze" } haze-materials = { module = "dev.chrisbanes.haze:haze-blur-materials", version.ref = "haze" }

View File

@ -4,6 +4,10 @@
<dict> <dict>
<key>CADisableMinimumFrameDurationOnPhone</key> <key>CADisableMinimumFrameDurationOnPhone</key>
<true/> <true/>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>CFBundleURLTypes</key> <key>CFBundleURLTypes</key>
<array> <array>
<dict> <dict>