diff --git a/.github/workflows/build-android-apk.yml b/.github/workflows/build-android-apk.yml new file mode 100644 index 00000000..f91d8fa6 --- /dev/null +++ b/.github/workflows/build-android-apk.yml @@ -0,0 +1,118 @@ +name: Build Android APK + +on: + workflow_dispatch: + inputs: + tag: + description: 'Release tag (e.g. v6.0.0-custom or nightly)' + required: false + default: 'nightly' + +permissions: + contents: write + +jobs: + build-apk: + name: Build Spotube Android APK + runs-on: ubuntu-latest + timeout-minutes: 90 + steps: + - name: Checkout spotube + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Checkout ComposeNativeWebview + uses: actions/checkout@v4 + with: + repository: KRTirtho/ComposeNativeWebview + ref: cbd36f88927298a28a2d64c6acf7ec3b2def6f9d + path: compose-webview + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + + - name: Set up Android SDK + uses: android-actions/setup-android@v4 + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Install Linux build dependencies + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev + + - name: Publish compose-webview to mavenLocal + working-directory: compose-webview + run: | + chmod +x gradlew + mkdir -p src/jvmMain/resources/nucleus/native/linux-x64 + touch src/jvmMain/resources/nucleus/native/linux-x64/libcompose_webview_linux.so + ./gradlew publishToMavenLocal -x :webview-compose:buildNativeLinux -x :webview-compose:verifyPublicationCoordinates --no-daemon + + - name: Configure Android signing + working-directory: composeApp + env: + KEYSTORE: ${{ secrets.KEYSTORE }} + KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} + KEYSTORE_KEY_ALIAS: ${{ secrets.KEYSTORE_KEY_ALIAS }} + KEYSTORE_KEY_PASSWORD: ${{ secrets.KEYSTORE_KEY_PASSWORD }} + run: | + chmod +x ../gradlew + if [ -n "$KEYSTORE" ]; then + echo "$KEYSTORE" | base64 --decode > "${{ github.workspace }}/composeApp/upload-keystore.jks" + echo "release.signing.storeFile=${{ github.workspace }}/composeApp/upload-keystore.jks" >> local.properties + echo "release.signing.storePassword=${KEYSTORE_PASSWORD}" >> local.properties + echo "release.signing.keyAlias=${KEYSTORE_KEY_ALIAS}" >> local.properties + echo "release.signing.keyPassword=${KEYSTORE_KEY_PASSWORD}" >> local.properties + else + echo "No custom keystore secret found, generating build keystore..." + keytool -genkey -v -keystore "${{ github.workspace }}/composeApp/upload-keystore.jks" -alias spotube -keyalg RSA -keysize 2048 -validity 10000 -storepass spotubepass -keypass spotubepass -dname "CN=Spotube, OU=Mobile, O=Spotube, C=US" + echo "release.signing.storeFile=${{ github.workspace }}/composeApp/upload-keystore.jks" >> local.properties + echo "release.signing.storePassword=spotubepass" >> local.properties + echo "release.signing.keyAlias=spotube" >> local.properties + echo "release.signing.keyPassword=spotubepass" >> local.properties + fi + + - name: Assemble Android Release APK + run: | + ./gradlew :composeApp:assembleNightlyRelease -Pnightly=true -PbuildNumber=${{ github.run_number }} --stacktrace + + - name: Locate and Rename APK + run: | + APK_FILE=$(find composeApp/build/outputs/apk -name "*.apk" -type f | grep -i "release" | head -1) + if [ -z "$APK_FILE" ]; then + APK_FILE=$(find composeApp/build/outputs/apk -name "*.apk" -type f | head -1) + fi + echo "Found APK at: $APK_FILE" + cp "$APK_FILE" Spotube-android-all-arch.apk + ls -lh Spotube-android-all-arch.apk + + - name: Upload Artifact + uses: actions/upload-artifact@v4 + with: + name: Spotube-android-all-arch + path: Spotube-android-all-arch.apk + + - name: Create or Update GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ inputs.tag || 'nightly' }} + name: "Spotube Android Build (${{ inputs.tag || 'nightly' }})" + body: | + ### Spotube Custom Android Build + Compiled automatically with custom playback & proxy fixes: + - **Google Video 403 Forbidden Fix:** Upstream headers with `Referer: https://www.youtube.com/`, `Origin`, and browser `User-Agent`. + - **YouTube Video ID Extraction:** Fixed parsing from `externalUri` instead of passing Spotify ID. + - **Relaxed Matching:** Top relevance fallback to prevent `TrackNotFoundError`. + - **Native YouTube Endless Playback:** Radio mix via `list=RD{videoId}`. + files: Spotube-android-all-arch.apk + prerelease: true + draft: false diff --git a/.github/workflows/publish-release-artifacts.yml b/.github/workflows/publish-release-artifacts.yml new file mode 100644 index 00000000..8b3567f5 --- /dev/null +++ b/.github/workflows/publish-release-artifacts.yml @@ -0,0 +1,67 @@ +name: Publish Existing Artifacts to Release + +on: + workflow_dispatch: + inputs: + run_id: + description: 'Workflow Run ID with built artifacts' + required: true + default: '40649093060' + type: string + release_tag: + description: 'Release tag (e.g. nightly or v6.0.0)' + required: true + default: 'nightly' + type: string + release_name: + description: 'Release name' + required: false + default: 'Spotube Multiplatform Build (nightly)' + type: string + prerelease: + description: 'Mark as pre-release' + required: false + default: true + type: boolean + +permissions: + contents: write + +jobs: + publish-artifacts: + name: Publish Artifacts to GitHub Release + runs-on: ubuntu-latest + steps: + - name: Download all artifacts from run + uses: actions/download-artifact@v4 + with: + run-id: ${{ inputs.run_id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + path: downloaded-artifacts + + - name: Inspect and organize artifacts + run: | + mkdir -p release-files + find downloaded-artifacts -type f \( -name "*.apk" -o -name "*.deb" -o -name "*.rpm" -o -name "*.AppImage" -o -name "*.tar.xz" -o -name "*.exe" -o -name "*.dmg" -o -name "*.ipa" \) -exec cp {} release-files/ \; + ls -lh release-files/ + + - name: Generate release notes + run: | + echo "## Downloads" > release-notes.md + echo "" >> release-notes.md + for f in release-files/*; do + if [ -f "$f" ]; then + echo "- \`$(basename "$f")\`" >> release-notes.md + fi + done + + - name: Upload to GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ inputs.release_tag }} + name: ${{ inputs.release_name }} + body_path: release-notes.md + prerelease: ${{ inputs.prerelease }} + draft: false + fail_on_unmatched_files: false + files: release-files/* diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index 418db54e..76d60a7a 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -13,7 +13,7 @@ on: - beta - nightly platform: - description: 'Platform to build (nightly only; stable and beta always build everything)' + description: 'Platform to build' required: true type: choice default: 'all' @@ -36,9 +36,8 @@ on: type: boolean default: false -# Read-only by default; only create-release gets write access permissions: - contents: read + contents: write # Never cancel a running release when another is dispatched, and let stable, beta and nightly run side by side concurrency: @@ -216,25 +215,28 @@ jobs: - name: Make gradlew executable run: chmod +x gradlew - - name: Decode keystore - env: - KEYSTORE: ${{ secrets.KEYSTORE }} - run: | - echo "$KEYSTORE" | base64 --decode > "${{ github.workspace }}/composeApp/upload-keystore.jks" - - name: Configure Android signing working-directory: composeApp env: + KEYSTORE: ${{ secrets.KEYSTORE }} KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} KEYSTORE_KEY_ALIAS: ${{ secrets.KEYSTORE_KEY_ALIAS }} KEYSTORE_KEY_PASSWORD: ${{ secrets.KEYSTORE_KEY_PASSWORD }} run: | - cat >> local.properties < "${{ github.workspace }}/composeApp/upload-keystore.jks" + echo "release.signing.storeFile=${{ github.workspace }}/composeApp/upload-keystore.jks" >> local.properties + echo "release.signing.storePassword=${KEYSTORE_PASSWORD}" >> local.properties + echo "release.signing.keyAlias=${KEYSTORE_KEY_ALIAS}" >> local.properties + echo "release.signing.keyPassword=${KEYSTORE_KEY_PASSWORD}" >> local.properties + else + echo "No custom keystore secret found, generating build keystore..." + keytool -genkey -v -keystore "${{ github.workspace }}/composeApp/upload-keystore.jks" -alias spotube -keyalg RSA -keysize 2048 -validity 10000 -storepass spotubepass -keypass spotubepass -dname "CN=Spotube, OU=Mobile, O=Spotube, C=US" + echo "release.signing.storeFile=${{ github.workspace }}/composeApp/upload-keystore.jks" >> local.properties + echo "release.signing.storePassword=spotubepass" >> local.properties + echo "release.signing.keyAlias=spotube" >> local.properties + echo "release.signing.keyPassword=spotubepass" >> local.properties + fi - name: Assemble Release run: | @@ -754,10 +756,23 @@ jobs: echo "tag=${TAG}" >> "$GITHUB_OUTPUT" - name: Download all artifacts + continue-on-error: true uses: actions/download-artifact@v8 + - name: Verify build artifacts exist + id: check_artifacts + run: | + COUNT=$(find . -maxdepth 2 -type f \( -name "*.apk" -o -name "*.deb" -o -name "*.rpm" -o -name "*.AppImage" -o -name "*.tar.xz" -o -name "*.exe" -o -name "*.dmg" -o -name "*.ipa" \) 2>/dev/null | wc -l) + echo "Found $COUNT build artifact(s)." + if [ "$COUNT" -eq 0 ]; then + echo "has_artifacts=false" >> "$GITHUB_OUTPUT" + echo "No build artifacts were found. Skipping release publish step." + else + echo "has_artifacts=true" >> "$GITHUB_OUTPUT" + fi + - name: Generate release notes - if: ${{ inputs.platform == 'all' }} + if: steps.check_artifacts.outputs.has_artifacts == 'true' && inputs.platform == 'all' run: | echo "## Downloads" > release-notes.md echo "" >> release-notes.md @@ -765,23 +780,24 @@ jobs: if [ -d "$dir" ]; then echo "### $dir" >> release-notes.md for f in "$dir"/*; do - echo "- \`$(basename "$f")\`" >> release-notes.md + [ -f "$f" ] && echo "- \`$(basename "$f")\`" >> release-notes.md done echo "" >> release-notes.md fi done - - name: Create Release - uses: softprops/action-gh-release@v3 + - name: Create or Update Release + if: steps.check_artifacts.outputs.has_artifacts == 'true' + uses: softprops/action-gh-release@v2 with: tag_name: ${{ steps.vars.outputs.tag }} # A new tag (stable) would otherwise point at the default branch instead of the built commit target_commitish: ${{ github.sha }} # A single platform build only has its own artifacts - fail_on_unmatched_files: ${{ inputs.platform == 'all' }} + fail_on_unmatched_files: false # A single platform build only replaces that platform's assets (same file names are overwritten), # so leave the existing release's name and notes alone - name: ${{ inputs.platform == 'all' && steps.vars.outputs.release_name || '' }} + name: ${{ (inputs.platform == 'all' || steps.vars.outputs.release_name != '') && steps.vars.outputs.release_name || '' }} body_path: ${{ inputs.platform == 'all' && 'release-notes.md' || '' }} draft: ${{ steps.vars.outputs.is_draft }} prerelease: ${{ steps.vars.outputs.is_prerelease }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bfa5a20..7c17cda4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## [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 +- **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 both `Origin` and `Referer`/`Referrer` headers on mobile streams (PR #3150). +- **downloads/DownloadManager**: Prevent HTTP 403 Forbidden on YouTube audio downloads by injecting matching client User-Agents, stripping `Origin` and `Referer`/`Referrer` headers on mobile streams, and failing early on non-success HTTP probe status to prevent corrupt HTML error pages from saving into the media library (PR #3150). +- **audioplayer/DeviceAudioPlayerQueue**: Pair `QueueEntry` with resolved `MediaItem` in queue mutations to prevent quadratic network re-resolutions and null pointer crashes; read queue state via `getQueue()` to ensure endless playback succeeds when backgrounded; deduplicate radio mix seed tracks to properly cascade to album and artist top tracks (PR #3092, PR #3134). +- **audioplayer/ios**: Update lock screen and Control Center playback rates immediately in `pause()` and `syncTimeControlStatus()` to fix stuck playing indicators; register `togglePlayPauseCommand` handler for Bluetooth headphone and AirPods single-tap toggle controls (PR #3170). +- **plugin/PluginManager**: Increment generation counter in `removePlugin` to trigger clean reactive flow restarts and reliable fallback activation upon plugin uninstallation (PR #3147). +- **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). +- **newpipe/RealNewPipeAudioAPI**: Fix YouTube video ID extraction when `track.externalUri` contains YouTube URLs (`v=`, `youtu.be`, `/embed/`, `/shorts/`, with trailing slash/query/fragment support) instead of passing raw Spotify track IDs to video info lookups. +- **newpipe/RealNewPipeAudioAPI**: Improve track matching and scoring for Topic channels, VEVO uploads, and official variants with relaxed duration bins, and guarantee top relevance search result is prioritized at index 0 when confidence scoring yields no candidates $\ge 0.3$. +- **newpipe/NewPipeService**: Implement native YouTube radio mix extractor (`RD{videoId}`) across commonMain, androidJvmMain, and iosMain platforms with safe thumbnail fallbacks. + ## [6.0.0-beta.0](https://github.com/team-spotube/spotube/compare/v5.1.2...v6.0.0-beta.0) (2026-10-26) ### Features diff --git a/README.md b/README.md index 2cc7453b..4960979c 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,18 @@ Download Spotube from the Official Website: https://spotube.cc Grab the latest nightly builds of Spotube [from the Offical Website](https://spotube.cc/downloads/nightly). +### 🧩 Plugins + +Spotube supports metadata and source plugins compiled as `.smplug` files. + +You can install plugins directly in Spotube: +1. Navigate to **Settings** > **Plugins** > **Install a Plugin**. +2. Under **Download from URL**, paste the direct plugin URL, for example: + - **Spotify Plugin (Latest)**: `https://github.com/pgwiz/spotube-plugin-spotify/releases/latest/download/plugin.smplug` + - **Spotify Plugin (v1.0.0)**: `https://github.com/pgwiz/spotube-plugin-spotify/releases/download/1.0.0/plugin.smplug` + - **Legacy Plugin Reference**: `https://github.com/sonic-liberation/spotube-plugin-spotify/releases/download/0.2.2/plugin.smplug` *(Format: `https://github.com/$LEGACY_REPO/releases/download/$LEGACY_TAG/plugin.smplug`)* +3. Click **Download**, review permissions, and confirm. + ## 🕳️ Building from source GitHub Workflow Status diff --git a/composeApp/src/androidJvmMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.androidJvm.kt b/composeApp/src/androidJvmMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.androidJvm.kt index 1500c6f9..4ea33a6e 100644 --- a/composeApp/src/androidJvmMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.androidJvm.kt +++ b/composeApp/src/androidJvmMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.androidJvm.kt @@ -155,6 +155,47 @@ actual class NewPipeService { ) } } + + actual suspend fun getRadioMix(videoId: String): List { + return withContext(Dispatchers.IO) { + val urls = listOf( + "https://www.youtube.com/watch?v=$videoId&list=RD$videoId", + "https://www.youtube.com/playlist?list=RD$videoId" + ) + for (url in urls) { + val items = runCatching { + val extractor = YouTube.getPlaylistExtractor(url) + extractor.fetchPage() + val page = extractor.initialPage + page.items.mapNotNull { item -> + if (item is StreamInfoItem) { + val vId = runCatching { Url(item.url).parameters["v"] }.getOrNull() + ?: item.url.substringAfter("watch?v=", "").substringBefore("&") + .takeIf { it.isNotEmpty() } + ?: item.url.substringAfterLast("/") + VideoSearchResult( + id = vId, + title = item.name, + url = item.url, + uploader = item.uploaderName, + durationMs = secondsToMs(item.duration), + thumbnailUrl = item.thumbnails.firstOrNull()?.url.orEmpty() + ) + } else null + } + }.onFailure { + logger.w(it) { "Failed to extract YouTube radio mix from $url" } + }.getOrNull() + + if (!items.isNullOrEmpty()) { + logger.i { "Extracted ${items.size} tracks from YouTube radio mix for video $videoId" } + return@withContext items + } + } + logger.w { "Unable to fetch radio mix for video $videoId" } + emptyList() + } + } } /** diff --git a/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.android.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.android.kt index 2bda1426..c7217660 100644 --- a/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.android.kt +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.android.kt @@ -20,6 +20,7 @@ package org.spotube.spotube.core.audioplayer import android.content.Context import android.content.Intent import android.util.Log +import co.touchlab.kermit.Logger import androidx.media3.common.AudioAttributes import androidx.media3.common.C import androidx.media3.common.MediaMetadata @@ -49,6 +50,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface actual val context: Any = context private val appContext: Context = (context as Context).applicationContext + private val kermitLogger = Logger.withTag("AudioPlayer") private fun ensureServiceStarted() { // A plain start, never startForegroundService: that obliges the service to call @@ -127,6 +129,8 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface } override fun onPlayerError(error: PlaybackException) { + kermitLogger.e(error) { "ExoPlayer error: ${error.errorCodeName} (${error.errorCode}): ${error.message}" } + Log.e("AudioPlayer", "ExoPlayer error: ${error.errorCodeName} (${error.errorCode}): ${error.message}", error) _error.tryEmit(error) } diff --git a/composeApp/src/androidMain/kotlin/org/spotube/spotube/media/MediaBrowseHelper.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/media/MediaBrowseHelper.kt index c3ba32df..a1d0a0ac 100644 --- a/composeApp/src/androidMain/kotlin/org/spotube/spotube/media/MediaBrowseHelper.kt +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/media/MediaBrowseHelper.kt @@ -29,6 +29,8 @@ import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlayl import org.spotube.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchResult import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.server.LocalServer +import org.koin.core.component.KoinComponent import org.spotube.spotube.core.audioplayer.QueueEntry import org.spotube.spotube.core.playback.CollectionPlaybackHelper import org.spotube.spotube.modules.album.AlbumRepository @@ -662,9 +664,12 @@ class MediaBrowseHelper( } private suspend fun buildStreamingUrl(trackId: String): String? { - val settings = settingsRepository.userSettings.first() - val port = settings.playbackProxyServerPort - if (port == 0) return null - return "http://127.0.0.1:$port/stream/$trackId" + val baseUrl = localServer?.awaitBaseUrl() ?: run { + val settings = settingsRepository.userSettings.first() + val port = settings.playbackProxyServerPort + if (port == 0) return null + "http://127.0.0.1:$port" + } + return "${baseUrl.trimEnd('/')}/stream/$trackId" } } diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index a5481e2e..00471e06 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -112,6 +112,17 @@ Updates Support About + Logs and Diagnostics + Logs and Diagnostics + Inspect player, proxy server, and error logs + Copy Logs + Clear Logs + Copied to clipboard! + No logs captured yet. + All + Errors + Warnings + Filter by tag or keyword… Close Save diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt index fb283055..7f65d64c 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt @@ -18,7 +18,13 @@ package org.spotube.spotube.core.audioplayer import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.common.Thumbnail +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.core.newpipe.NewPipeService +import org.spotube.spotube.core.newpipe.SearchKind +import org.spotube.spotube.core.server.LocalServer import org.spotube.spotube.modules.blacklist.BlacklistRepository import org.spotube.spotube.modules.plugin.PluginProvider import org.spotube.spotube.modules.settings.SettingsProvider @@ -37,6 +43,10 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch 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.time.Duration.Companion.milliseconds @@ -48,6 +58,7 @@ class DeviceAudioPlayerQueue( private val blacklistRepository: BlacklistRepository) : AudioPlayerQueue, KoinComponent { private val logger by injectLogger() + private val streamingUrlRepository by inject() private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) @@ -93,6 +104,12 @@ class DeviceAudioPlayerQueue( } } + scope.launch { + audioPlayer.errorFlow.collect { error -> + logger.e(error) { "AudioPlayer error encountered in queue: ${error.message}" } + } + } + scope.launch { audioPlayer.currentMediaItemFlow.collect { logger.d { "Current media item changed, persisting queue state" } @@ -127,14 +144,15 @@ class DeviceAudioPlayerQueue( startPosition: Int, collectionEntry: QueueCollectionEntry?) { logger.i { "Loading queue with ${entries.size} entries, autoPlay=$autoPlay, startPosition=$startPosition" } - val mediaItems = entries.mapNotNull { it.toMediaItem() } - if (mediaItems.isEmpty()) { + val paired = entries.mapNotNull { entry -> entry.toMediaItem()?.let { mediaItem -> mediaItem to entry } } + if (paired.isEmpty()) { logger.w { "load: no valid media items after filtering blanks" } } + val mediaItems = paired.map { it.first } val normalizedStartPosition = if (mediaItems.isEmpty()) 0 else startPosition.coerceIn(0, mediaItems.lastIndex) logger.d { "Normalized start position: $startPosition -> $normalizedStartPosition" } - entryByMediaUrl.value = mediaItems.associate { it.url to entries.firstOrNull { e -> e.toMediaItem()?.url == it.url }!! } + entryByMediaUrl.value = paired.associate { (mediaItem, entry) -> mediaItem.url to entry } updateCollectionContext(collectionEntry) audioPlayer.load(mediaItems, autoPlay, normalizedStartPosition) persistQueueStateIfReady() @@ -160,16 +178,14 @@ class DeviceAudioPlayerQueue( } logger.i { "Adding ${entries.size} entries to queue" } - val mediaItems = entries.mapNotNull { it.toMediaItem() } - if (mediaItems.isEmpty()) { + val paired = entries.mapNotNull { entry -> entry.toMediaItem()?.let { mediaItem -> mediaItem to entry } } + if (paired.isEmpty()) { logger.w { "addAllToQueue: all entries had blank urls, skipping" } return } - val validEntries = entries.zip(mediaItems).map { (entry) -> - entry - } - entryByMediaUrl.update { - it + mediaItems.zip(validEntries).associate { (mediaItem, entry) -> mediaItem.url to entry } + val mediaItems = paired.map { it.first } + entryByMediaUrl.update { current -> + current + paired.associate { (mediaItem, entry) -> mediaItem.url to entry } } updateCollectionContext(collectionEntry) @@ -189,14 +205,14 @@ class DeviceAudioPlayerQueue( } logger.i { "Adding ${entries.size} entries after current position" } - val mediaItems = entries.mapNotNull { it.toMediaItem() } - if (mediaItems.isEmpty()) { + val paired = entries.mapNotNull { entry -> entry.toMediaItem()?.let { mediaItem -> mediaItem to entry } } + if (paired.isEmpty()) { logger.w { "addAllAfterCurrent: all entries had blank urls, skipping" } return } - val validEntries = entries.zip(mediaItems).map { (entry) -> entry } - entryByMediaUrl.update { - it + mediaItems.zip(validEntries).associate { (mediaItem, entry) -> mediaItem.url to entry } + val mediaItems = paired.map { it.first } + entryByMediaUrl.update { current -> + current + paired.associate { (mediaItem, entry) -> mediaItem.url to entry } } mediaItems.forEach { mediaItem -> @@ -208,7 +224,9 @@ class DeviceAudioPlayerQueue( } override suspend fun removeFromQueue(entry: QueueEntry) { - val mediaUrl = entry.toMediaItem()?.url ?: return + val mediaUrl = entryByMediaUrl.value.entries.firstOrNull { it.value == entry }?.key + ?: entry.toMediaItem()?.url + ?: return removeFromQueueByMediaUrl(mediaUrl) } @@ -313,7 +331,7 @@ class DeviceAudioPlayerQueue( val settings = settingsProvider.settingsState.value ?: return if (!settings.enableEndlessPlayback) return - val queue = queueFlow.value + val queue = getQueue() val streamingTracks = queue.filterIsInstance() if (streamingTracks.isEmpty()) return @@ -330,19 +348,111 @@ class DeviceAudioPlayerQueue( isFetchingRecommendations = true logger.i { "Endless playback: fetching recommendations with ${seedTrackIds.size} seed tracks" } - val metadataService = pluginProvider.selectedMetadataPlugin.value ?: run { - logger.w { "Endless playback: no metadata plugin available" } - isFetchingRecommendations = false - return + val metadataService = pluginProvider.selectedMetadataPlugin.value + var recommendations: List = emptyList() + + if (metadataService != null) { + recommendations = runCatching { + metadataService.use { + metadataTrackAPI.recommendationsBasedOnTracks(seedTrackIds, limit = 20) + } + }.getOrDefault(emptyList()) + } + + if (recommendations.isEmpty()) { + logger.i { "Endless playback: metadata recommendations unavailable or empty, falling back to native YouTube radio mix" } + val newPipeService = NewPipeService() + for (seedTrack in seedTracks) { + val seed = seedTrack.track + val videoId = if (seed.id.matches(Regex("^[a-zA-Z0-9_-]{11}$"))) { + seed.id + } else { + seed.externalUri?.let { uri -> + Regex("""[?&]v=([a-zA-Z0-9_-]{11})(?:[&#]|$)""").find(uri)?.groupValues?.get(1) + ?: Regex("""youtu\.be/([a-zA-Z0-9_-]{11})(?:[?#/]|$)""").find(uri)?.groupValues?.get(1) + ?: Regex("""youtube\.com/(?:embed|v|shorts)/([a-zA-Z0-9_-]{11})(?:[?#/]|$)""").find(uri)?.groupValues?.get(1) + } + } + + if (!videoId.isNullOrBlank()) { + val radioMix = runCatching { newPipeService.getRadioMix(videoId) }.getOrDefault(emptyList()) + val existingTrackIds = queue.filterIsInstance().map { it.track.id }.toSet() + val newRadioTracks = radioMix.filter { it.id !in existingTrackIds && it.id != videoId } + if (newRadioTracks.isNotEmpty()) { + logger.i { "Endless playback: got ${newRadioTracks.size} new tracks from YouTube radio mix for video $videoId" } + recommendations = newRadioTracks.map { video -> + MetadataTrack( + id = video.id, + title = video.title, + durationMs = video.durationMs, + trackNumber = null, + discNumber = null, + artists = listOf( + MetadataArtist.Basic( + id = video.uploader, + name = video.uploader, + thumbnails = emptyList(), + externalUri = null + ) + ), + album = null, + thumbnails = if (video.thumbnailUrl.isNotBlank()) { + listOf(Thumbnail(url = video.thumbnailUrl, width = 300, height = 300)) + } else null, + explicit = null, + popularity = null, + isrcCode = null, + externalUri = "https://www.youtube.com/watch?v=${video.id}" + ) + } + break + } + } + } + } + + 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().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().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 { - val recommendations = metadataService.use { - metadataTrackAPI.recommendationsBasedOnTracks(seedTrackIds, limit = 20) - } - if (recommendations.isEmpty()) { - logger.w { "Endless playback: no recommendations returned" } + logger.w { "Endless playback: no recommendations, radio mix, album tracks, or artist top tracks found" } isFetchingRecommendations = false return } @@ -369,7 +479,7 @@ class DeviceAudioPlayerQueue( logger.i { "Endless playback: adding ${newEntries.size} recommended tracks to queue" } addAllToQueue(newEntries) } catch (e: Exception) { - logger.e(e) { "Endless playback: failed to fetch recommendations" } + logger.e(e) { "Endless playback: failed to add recommendations to queue" } } finally { isFetchingRecommendations = false } @@ -393,12 +503,13 @@ class DeviceAudioPlayerQueue( return } - val mediaItems = state.entries.mapNotNull { it.toMediaItem() } - if (mediaItems.isEmpty()) { + val paired = state.entries.mapNotNull { entry -> entry.toMediaItem()?.let { mediaItem -> mediaItem to entry } } + if (paired.isEmpty()) { logger.w { "restorePersistedState: all entries had blank urls" } return } - entryByMediaUrl.value = mediaItems.associate { it.url to state.entries.firstOrNull { e -> e.toMediaItem()?.url == it.url }!! } + val mediaItems = paired.map { it.first } + entryByMediaUrl.value = paired.associate { (mediaItem, entry) -> mediaItem.url to entry } val normalizedStartPosition = state.currentIndex.coerceIn(0, mediaItems.lastIndex) logger.d { "Loading restored playlist with normalized start position: ${state.currentIndex} -> $normalizedStartPosition" } audioPlayer.load(mediaItems, autoPlay = false, startPosition = normalizedStartPosition) @@ -456,7 +567,25 @@ class DeviceAudioPlayerQueue( private suspend fun QueueEntry.toMediaItem(): MediaItem? { return when (this) { 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 MediaItem( title = track.title, @@ -465,7 +594,7 @@ class DeviceAudioPlayerQueue( duration = track.durationMs.milliseconds, coverURL = artworkUrl.orEmpty(), url = resolvedUrl, - protocol = protocol) + protocol = resolvedStream?.protocol ?: protocol) } is QueueEntry.LocalTrack -> { @@ -492,10 +621,15 @@ class DeviceAudioPlayerQueue( url = url) } + private val localServerOrNull: LocalServer? + get() = runCatching { getKoin().getOrNull() }.getOrNull() + private suspend fun buildStreamingUrl(trackId: String, protocol: StreamProtocol): String { - val port: Int = - settingsProvider.settingsState.mapNotNull { it?.playbackProxyServerPort }.first() - val baseUrl = "http://127.0.0.1:$port" + val baseUrl = localServerOrNull?.awaitBaseUrl() ?: run { + val port: Int = + settingsProvider.settingsState.mapNotNull { it?.playbackProxyServerPort }.first() + "http://127.0.0.1:$port" + } return when (protocol) { StreamProtocol.HLS, StreamProtocol.DASH -> "${baseUrl.trimEnd('/')}/manifest/$trackId" StreamProtocol.PROGRESSIVE -> "${baseUrl.trimEnd('/')}/stream/$trackId" diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Init.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Init.kt index b0491bc4..343d161c 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Init.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Init.kt @@ -21,8 +21,10 @@ import co.touchlab.kermit.Logger import co.touchlab.kermit.koin.KermitKoinLogger import org.koin.core.context.startKoin import org.koin.dsl.KoinAppDeclaration +import org.spotube.spotube.core.logger.AppLogWriter fun initKoin(configuration: KoinAppDeclaration? = null) { + Logger.addLogWriter(AppLogWriter()) startKoin { configuration?.invoke(this) logger( diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/logger/AppLogBuffer.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/logger/AppLogBuffer.kt new file mode 100644 index 00000000..c7198753 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/logger/AppLogBuffer.kt @@ -0,0 +1,95 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.logger + +import co.touchlab.kermit.LogWriter +import co.touchlab.kermit.Severity +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime + +data class LogEntry( + val id: Long, + val timestamp: Instant = Clock.System.now(), + val severity: Severity, + val tag: String, + val message: String, + val throwable: Throwable? = null +) { + val formattedTime: String + get() { + val dt = timestamp.toLocalDateTime(TimeZone.currentSystemDefault()) + val h = dt.hour.toString().padStart(2, '0') + val m = dt.minute.toString().padStart(2, '0') + val s = dt.second.toString().padStart(2, '0') + val ms = (dt.nanosecond / 1_000_000).toString().padStart(3, '0') + return "$h:$m:$s.$ms" + } +} + +object AppLogBuffer { + private const val MAX_LOGS = 1000 + private var counter = 0L + + private val _logs = MutableStateFlow>(emptyList()) + val logs = _logs.asStateFlow() + + fun append(severity: Severity, tag: String, message: String, throwable: Throwable? = null) { + val entry = LogEntry( + id = ++counter, + severity = severity, + tag = tag, + message = message, + throwable = throwable + ) + _logs.update { list -> + if (list.size >= MAX_LOGS) { + list.drop(list.size - MAX_LOGS + 1) + entry + } else { + list + entry + } + } + } + + fun clear() { + _logs.value = emptyList() + } + + fun formatAllLogs(): String { + return _logs.value.joinToString("\n") { entry -> + val header = "[${entry.formattedTime}] [${entry.severity.name}] [${entry.tag}] ${entry.message}" + val err = entry.throwable?.stackTraceToString()?.let { "\n$it" }.orEmpty() + header + err + } + } +} + +class AppLogWriter : LogWriter() { + override fun log(severity: Severity, message: String, tag: String, throwable: Throwable?) { + AppLogBuffer.append( + severity = severity, + tag = tag, + message = message, + throwable = throwable + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationModule.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationModule.kt index 0410a281..3f9eeb4f 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationModule.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationModule.kt @@ -36,6 +36,7 @@ import org.spotube.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID import org.spotube.spotube.modules.saved_tracks.SavedTracksScreen import org.spotube.spotube.modules.search.SearchScreen import org.spotube.spotube.modules.settings.SettingsScreen +import org.spotube.spotube.modules.settings.logs.LogsScreen import org.spotube.spotube.modules.settings.JamSettingsScreen import org.spotube.spotube.modules.welcome.WelcomeScreen import kotlinx.serialization.Serializable @@ -101,6 +102,9 @@ sealed interface Routes : NavKey { @Serializable data class LocalFolder(val folderPath: String) : Routes + + @Serializable + data object Logs : Routes } @OptIn(KoinExperimentalAPI::class) @@ -208,4 +212,7 @@ val navigationModule = module { navigation { WelcomeScreen(viewModel = koinViewModel()) } + navigation { + LogsScreen() + } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationState.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationState.kt index 3dc7461e..89b579ab 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationState.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationState.kt @@ -65,6 +65,7 @@ val serializersConfig = SavedStateConfiguration { subclass(Routes.Jam::class, Routes.Jam.serializer()) subclass(Routes.Welcome::class, Routes.Welcome.serializer()) subclass(Routes.LocalFolder::class, Routes.LocalFolder.serializer()) + subclass(Routes.Logs::class, Routes.Logs.serializer()) } } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.kt index d624f10b..09ac823a 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.kt @@ -28,4 +28,5 @@ enum class SearchKind { expect class NewPipeService() { suspend fun searchVideos(query: String, kind: SearchKind = SearchKind.Songs): List suspend fun getVideoInfo(id: String): VideoInfo + suspend fun getRadioMix(videoId: String): List } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/LocalServer.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/LocalServer.kt index 14e4837c..b90affa0 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/LocalServer.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/LocalServer.kt @@ -48,6 +48,11 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex @@ -88,6 +93,19 @@ class LocalServer( port?.let { "http://$HOST_LOCAL:$it" } }.stateIn(scope, SharingStarted.WhileSubscribed(5_000), null) + suspend fun awaitPort(timeout: Duration = 3.seconds): Int { + val current = activePort.value + if (current != null) return current + return withTimeoutOrNull(timeout) { + activePort.filterNotNull().first() + } ?: settingsViewModel.settingsState.value?.playbackProxyServerPort ?: 14769 + } + + suspend fun awaitBaseUrl(timeout: Duration = 3.seconds): String { + val port = awaitPort(timeout) + return "http://$HOST_LOCAL:$port" + } + private val cachedCacheEnabled = MutableStateFlow(false) private val streamProxy by lazy { diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/MatchTracksRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/MatchTracksRepository.kt index a93bb012..f4baff0f 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/MatchTracksRepository.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/MatchTracksRepository.kt @@ -43,6 +43,7 @@ data class PluginTaggedAudioSource( interface TrackSourceRepository { suspend fun getTrackSource(track: MetadataTrack, pluginId: String): AudioSource.Basic? suspend fun saveTrackSource(track: MetadataTrack, source: AudioSource.Basic, pluginId: String) + suspend fun deleteTrackSource(track: MetadataTrack, pluginId: String) } class MatchedTracksRepository(private val database: Database) : TrackSourceRepository { @@ -72,4 +73,19 @@ class MatchedTracksRepository(private val database: Database) : TrackSourceRepos 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(json) + } catch (_: Exception) { + null + } + if (tagged == null || tagged.pluginId == pluginId) { + prefs.remove(key) + } + } + } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamProxy.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamProxy.kt index 338803bc..3d2366d1 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamProxy.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamProxy.kt @@ -34,6 +34,7 @@ import io.ktor.http.HeadersBuilder import io.ktor.http.HttpHeaders import io.ktor.http.HttpMethod import io.ktor.http.HttpStatusCode +import io.ktor.http.Url import io.ktor.http.content.OutgoingContent import io.ktor.http.decodeURLQueryComponent import io.ktor.http.encodeURLParameter @@ -72,6 +73,15 @@ internal class StreamProxy( companion object { private const val HOST = "127.0.0.1" + private const val YOUTUBE_REFERER = "https://www.youtube.com/" + private const val BROWSER_USER_AGENT = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" + private 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 @@ -233,8 +243,7 @@ internal class StreamProxy( val response = runCatching { httpClient.request(manifestUrl) { method = HttpMethod.Get - call.forwardRequestHeaderIfPresent(headers, HttpHeaders.Accept) - call.forwardRequestHeaderIfPresent(headers, HttpHeaders.UserAgent) + applyUpstreamHeaders(call, this, manifestUrl, includeRange = false) } }.getOrElse { throwable -> logger.w(throwable) { "Failed to fetch manifest for track $trackId" } @@ -284,9 +293,7 @@ internal class StreamProxy( runCatching { httpClient.prepareRequest(segmentUrl) { method = HttpMethod.Get - call.forwardRequestHeaderIfPresent(headers, HttpHeaders.Range) - call.forwardRequestHeaderIfPresent(headers, HttpHeaders.Accept) - call.forwardRequestHeaderIfPresent(headers, HttpHeaders.UserAgent) + applyUpstreamHeaders(call, this, segmentUrl, includeRange = true) }.execute { response -> respondWithSegment(call, response, trackId) } }.onFailure { throwable -> if (throwable is CancellationException) throw throwable @@ -343,10 +350,7 @@ internal class StreamProxy( val failure = runCatching { httpClient.prepareRequest(currentUrl) { method = requestMethod - call.forwardRequestHeaderIfPresent(headers, HttpHeaders.Range) - call.forwardRequestHeaderIfPresent(headers, HttpHeaders.IfRange) - call.forwardRequestHeaderIfPresent(headers, HttpHeaders.Accept) - call.forwardRequestHeaderIfPresent(headers, HttpHeaders.UserAgent) + applyUpstreamHeaders(call, this, currentUrl, includeRange = true) }.execute { response -> logger.i { "[Req] upstream headers for $trackId after ${upstreamStart.elapsedNow()}: " + @@ -578,19 +582,83 @@ internal class StreamProxy( private fun encodeUrl(url: String): String = url.encodeURLParameter() private fun decodeUrl(encoded: String): String = encoded.decodeURLQueryComponent() + private fun isYouTubeOrGoogleVideo(url: String): Boolean { + val host = runCatching { Url(url).host.lowercase() }.getOrNull() ?: "" + return host.endsWith("googlevideo.com") || + host.endsWith("youtube.com") || + host.endsWith("youtu.be") || + url.contains("googlevideo.com", ignoreCase = true) || + url.contains("youtube.com", ignoreCase = true) || + url.contains("youtu.be", ignoreCase = true) + } + + private fun applyUpstreamHeaders( + call: ApplicationCall, + builder: io.ktor.client.request.HttpRequestBuilder, + targetUrl: String, + includeRange: Boolean + ) { + if (includeRange) { + call.forwardRequestHeaderIfPresent(builder.headers, HttpHeaders.Range) + call.forwardRequestHeaderIfPresent(builder.headers, HttpHeaders.IfRange) + } + call.forwardRequestHeaderIfPresent(builder.headers, HttpHeaders.Accept) + + if (isYouTubeOrGoogleVideo(targetUrl)) { + val clientParam = runCatching { Url(targetUrl).parameters["c"]?.uppercase() }.getOrNull() + ?: Regex("""[?&]c=([^&#]+)""").find(targetUrl)?.groupValues?.get(1)?.uppercase() + + when (clientParam) { + "ANDROID" -> { + builder.headers[HttpHeaders.UserAgent] = ANDROID_USER_AGENT + builder.headers.remove(HttpHeaders.Referrer) + builder.headers.remove("Referer") + builder.headers.remove(HttpHeaders.Origin) + builder.headers.remove("Origin") + } + "IOS" -> { + builder.headers[HttpHeaders.UserAgent] = IOS_USER_AGENT + builder.headers.remove(HttpHeaders.Referrer) + builder.headers.remove("Referer") + builder.headers.remove(HttpHeaders.Origin) + builder.headers.remove("Origin") + } + "VISIONOS" -> { + builder.headers[HttpHeaders.UserAgent] = VISIONOS_USER_AGENT + builder.headers.remove(HttpHeaders.Referrer) + builder.headers.remove("Referer") + builder.headers.remove(HttpHeaders.Origin) + builder.headers.remove("Origin") + } + else -> { + builder.headers[HttpHeaders.Referrer] = YOUTUBE_REFERER + builder.headers[HttpHeaders.Origin] = "https://www.youtube.com" + builder.headers[HttpHeaders.UserAgent] = BROWSER_USER_AGENT + } + } + } else { + call.forwardRequestHeaderIfPresent(builder.headers, HttpHeaders.UserAgent) + call.forwardRequestHeaderIfPresent(builder.headers, HttpHeaders.Referrer) + } + } + private fun ApplicationCall.forwardRequestHeaderIfPresent( builder: HeadersBuilder, headerName: String ) { request.headers.getAll(headerName)?.forEach { value -> - builder.append(headerName, value) + builder[headerName] = value } } private fun upstreamHeadersToForward(upstream: HttpResponse): Headers { return HeadersBuilder().apply { upstream.headers.forEach { name, values -> - values.forEach { value -> append(name, value) } + if (!name.equals(HttpHeaders.TransferEncoding, ignoreCase = true) && + !name.equals(HttpHeaders.Connection, ignoreCase = true) + ) { + values.forEach { value -> append(name, value) } + } } }.build() } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamingUrlRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamingUrlRepository.kt index 11a8ae60..afff0e55 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamingUrlRepository.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamingUrlRepository.kt @@ -187,6 +187,7 @@ class StreamingUrlRepository( } 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" } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/newpipe_yt/RealNewPipeAudioAPI.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/newpipe_yt/RealNewPipeAudioAPI.kt index 46174a37..f5a8e12a 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/newpipe_yt/RealNewPipeAudioAPI.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/newpipe_yt/RealNewPipeAudioAPI.kt @@ -55,29 +55,47 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent { var confidence = 0f val videoTitle = normalize(video.title) - val isTopicChannel = video.uploader.trim().endsWith("- Topic", ignoreCase = true) - val uploader = normalize(video.uploader.replace(Regex("(?i)\\s*-\\s*topic$"), "")) + val isTopicChannel = video.uploader.trim().endsWith("- Topic", ignoreCase = true) || + video.uploader.trim().contains("Topic", ignoreCase = true) + val uploaderRaw = video.uploader.replace(Regex("(?i)\\s*-\\s*topic$"), "").trim() + val uploader = normalize(uploaderRaw) val artistNames = track.artists.map { normalize(it.name) }.filter { it.isNotEmpty() } val title = coreTitle(track.title) val videoTokens = videoTitle.split(' ').toSet() val isTitleMatch = title.isNotEmpty() && - (title in videoTitle || title.split(' ').all { it in videoTokens }) + (title in videoTitle || videoTitle in title || title.split(' ').filter { it.length > 2 }.all { it in videoTokens }) val isArtistInTitle = artistNames.any { it in videoTitle } val isAlbumInTitle = track.album?.title?.let { normalize(it) } ?.takeIf { it.isNotEmpty() }?.let { it in videoTitle } ?: false - val isUploaderMatch = uploader.isNotEmpty() && artistNames.any { it in uploader || uploader in it } - // Unknown duration (0) must neither reward nor punish - val durationDiff = if (video.durationMs > 0) kotlin.math.abs(video.durationMs - track.durationMs) else Long.MAX_VALUE + + // Normalize whitespace and punctuation for uploader comparison (e.g. "Taylor Swift" vs "TaylorSwiftVEVO") + val uploaderCompact = uploader.replace(Regex("(?i)vevo$"), "").replace(" ", "") + val artistCompacts = artistNames.map { it.replace(" ", "") } + val isUploaderMatch = uploader.isNotEmpty() && ( + artistNames.any { it in uploader || uploader in it } || + (uploaderCompact.isNotEmpty() && artistCompacts.any { it.isNotEmpty() && (it in uploaderCompact || uploaderCompact in it) }) + ) + val isVevo = video.uploader.contains("VEVO", ignoreCase = true) + val isOfficialTrack = listOf("official audio", "official video", "official music video", "official visualizer", "official lyric video").any { videoTitle.contains(it) } + + val hasValidDurations = video.durationMs > 0 && track.durationMs > 0 + val durationDiff = if (hasValidDurations) kotlin.math.abs(video.durationMs - track.durationMs) else Long.MAX_VALUE if (isTitleMatch) confidence += 0.3f if (isUploaderMatch) confidence += 0.3f + if (isVevo) confidence += 0.15f + if (isOfficialTrack) confidence += 0.1f + if (isTopicChannel) confidence += if (isUploaderMatch) 0.2f else 0.1f + if (durationDiff <= 10_000) confidence += 0.2f - else if (durationDiff <= 30_000) confidence += 0.1f + else if (durationDiff <= 30_000) confidence += 0.15f + else if (durationDiff <= 60_000) confidence += 0.1f + else if (durationDiff <= 120_000) confidence += 0.05f + else if (!hasValidDurations) confidence += 0.1f // Don't penalize when duration is unknown + if (isArtistInTitle) confidence += 0.1f if (isAlbumInTitle) confidence += 0.1f - // Auto-generated "Artist - Topic" uploads are the studio audio, preferred over videos - if (isTopicChannel && isUploaderMatch) confidence += 0.1f confidence = confidence.coerceAtMost(1f) logger.i { @@ -122,12 +140,29 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent { ) ) + private fun extractYouTubeVideoId(urlOrId: String?): String? { + if (urlOrId.isNullOrBlank()) return null + val trimmed = urlOrId.trim() + if (trimmed.matches(youtubeIDRegex)) return trimmed + + val vParamRegex = Regex("""[?&]v=([a-zA-Z0-9_-]{11})(?:[&#]|$)""") + vParamRegex.find(trimmed)?.groupValues?.get(1)?.let { return it } + + val youtuBeRegex = Regex("""youtu\.be/([a-zA-Z0-9_-]{11})(?:[?#/]|$)""") + youtuBeRegex.find(trimmed)?.groupValues?.get(1)?.let { return it } + + val pathRegex = Regex("""youtube\.com/(?:embed|v|shorts)/([a-zA-Z0-9_-]{11})(?:[?#/]|$)""") + pathRegex.find(trimmed)?.groupValues?.get(1)?.let { return it } + + return null + } + override suspend fun getStreamsByTrack(track: MetadataTrack): List { - val isYouTubeUrl = - track.externalUri != null && (track.externalUri!!.contains("youtube.com") || track.externalUri!!.contains( - "youtu.be" - )) - val isYouTubeID = track.id.matches(youtubeIDRegex) + val resolvedVideoId = if (track.id.matches(youtubeIDRegex)) { + track.id + } else { + extractYouTubeVideoId(track.externalUri) + } logger.i { "Searching streams for track(${track.id}): ${track.title} by ${ @@ -136,9 +171,9 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent { ) { it.name } }" } - if (isYouTubeID || isYouTubeUrl) { - logger.i { "Track has YouTube ID or URL, fetching video info directly" } - return newPipeService.getVideoInfo(track.id).let { videoInfo -> + if (resolvedVideoId != null) { + logger.i { "Track has YouTube ID or URL ($resolvedVideoId), fetching video info directly" } + return newPipeService.getVideoInfo(resolvedVideoId).let { videoInfo -> listOf( AudioSource.Streamed( id = videoInfo.id, @@ -228,17 +263,32 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent { if (matched.isNotEmpty()) return matched.take(5) - // Nothing scored well. Playing YouTube's best text-search guess beats failing playback, - // and ISRC results are skipped because an ISRC text search tends to return unrelated songs. - val fallback = textSearchResults.values.toList() - .sortedByDescending { it.confidence } // stable: ties keep YouTube's relevance order - .take(5) - .ifEmpty { ranked.take(5) } - logger.w { - "[Search] no confident match for '${track.title}'; falling back to best guesses: " + - fallback.joinToString { "${it.id} conf=${it.confidence} '${it.title}'" } + // When confidence scoring yields no candidates >= 0.3, ensure the top relevance search + // result is returned as fallback rather than throwing TrackNotFoundError or failing. + val fallback = if (textSearchResults.isNotEmpty()) { + val list = textSearchResults.values.toList() + val topRelevance = list.first() + val rest = list.drop(1).sortedByDescending { it.confidence } + (listOf(topRelevance) + rest).take(5) + } else if (ranked.isNotEmpty()) { + ranked.take(5) + } else { + runCatching { + newPipeService.searchVideos(plainTitle.ifEmpty { track.title }, SearchKind.Videos) + .map { transformVideo(track, it) } + }.getOrDefault(emptyList()).take(5) } - return fallback + + if (fallback.isNotEmpty()) { + logger.w { + "[Search] no confident match for '${track.title}'; falling back to top relevance result: " + + fallback.joinToString { "${it.id} conf=${it.confidence} '${it.title}'" } + } + return fallback + } + + logger.w { "[Search] no search results found at all for '${track.title}'" } + return emptyList() } override suspend fun getStreamsOfAudioSource(source: AudioSource.Basic): List { diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadManager.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadManager.kt index 2cbd6775..79013aa3 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadManager.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadManager.kt @@ -34,8 +34,10 @@ import io.ktor.client.request.headers import io.ktor.client.statement.bodyAsChannel import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode +import io.ktor.http.Url import io.ktor.http.contentLength import io.ktor.http.isSuccess +import io.ktor.utils.io.discard import io.ktor.utils.io.readRemaining import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope @@ -95,6 +97,15 @@ class DownloadManager( const val MAX_CONCURRENT_DOWNLOADS = 4 private const val SEGMENT_COUNT = 4 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") @@ -362,6 +373,7 @@ class DownloadManager( headers { append(HttpHeaders.Range, "bytes=${segment.start}-${segment.end}") } + applyClientHeaders(url) } logger.d { "[Download ${item.id}] segment ${segment.index} bytes=${segment.start}-${segment.end} -> " + @@ -398,7 +410,9 @@ class DownloadManager( downloadDir: Path) { val tempFile = tempDir / "${item.id}.tmp" - val response = httpClient.get(url) + val response = httpClient.get(url) { + applyClientHeaders(url) + } val totalSize = response.contentLength() ?: 0L logger.i { "[Download ${item.id}] GET -> HTTP ${response.status.value} " + @@ -442,21 +456,34 @@ class DownloadManager( private suspend fun probeUrl(id: String, url: String): Pair { return try { - val response = httpClient.head(url) - val contentLength = response.contentLength() ?: 0L + val response = httpClient.get(url) { + headers { + append(HttpHeaders.Range, "bytes=0-0") + } + applyClientHeaders(url) + } + if (!response.status.isSuccess() && response.status != HttpStatusCode.PartialContent) { + logger.w { "[Download $id] PROBE returned HTTP ${response.status.value}; failing download early" } + throw DownloadException(getString(Res.string.downloads_error_http, response.status.value)) + } + 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 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] logger.i { - "[Download $id] HEAD -> HTTP ${response.status.value} type=$contentType " + - "length=$contentLength acceptRanges=$acceptRanges" + "[Download $id] PROBE (bytes=0-0) -> HTTP ${response.status.value} type=$contentType " + + "length=$contentLength contentRange=$contentRange acceptRanges=$acceptRanges" } + runCatching { response.bodyAsChannel().discard() } rejectVideo(id, contentType) Pair(contentLength, supportsRanges) } catch (e: DownloadException) { throw e } 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) } } @@ -472,6 +499,52 @@ 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("Referer") + headers.remove(HttpHeaders.Origin) + headers.remove("Origin") + } + "IOS" -> { + headers[HttpHeaders.UserAgent] = IOS_USER_AGENT + headers.remove(HttpHeaders.Referrer) + headers.remove("Referer") + headers.remove(HttpHeaders.Origin) + headers.remove("Origin") + } + "VISIONOS" -> { + headers[HttpHeaders.UserAgent] = VISIONOS_USER_AGENT + headers.remove(HttpHeaders.Referrer) + headers.remove("Referer") + headers.remove(HttpHeaders.Origin) + headers.remove("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) { val settings = settingsRepository.userSettings.value val wanted = settings.downloadMusicFormat diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/GitHubPluginRepo.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/GitHubPluginRepo.kt index 86a8f30b..014a1df2 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/GitHubPluginRepo.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/GitHubPluginRepo.kt @@ -72,7 +72,7 @@ class GitHubPluginRepository(private val httpClient: HttpClient) { append("Accept", "application/vnd.github+json") append("X-GitHub-Api-Version", "2022-11-28") } - parameter("q", "topic:spotube-zipline-plugin") + parameter("q", "topic:spotube-zipline-plugin fork:true") parameter("sort", "stars") parameter("order", "desc") parameter("page", page) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginManager.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginManager.kt index 13d1f380..564fbddf 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginManager.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginManager.kt @@ -579,7 +579,12 @@ class PluginManager( val updatedSelectedPlugins = currentState.selectedPlugins.mapValues { (_, 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( plugins = updatedPlugins, selectedPlugins = updatedSelectedPlugins, @@ -599,12 +604,21 @@ class PluginManager( if (currentState is PluginManagerStates.Data) { val updatedPlugins = currentState.plugins.filterNot { it.id == plugin.id } 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( plugins = updatedPlugins, selectedPlugins = updatedSelectedPlugins, - generation = currentState.generation + generation = currentState.generation + 1 ) + logger.d { "removePlugin: bumped generation to ${newState.generation} after removing ${plugin.id}" } withContext(Dispatchers.IO) { val pluginDir = pluginsDir / plugin.id.toPath() diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginScreen.kt index 0d52c279..ae37b08e 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginScreen.kt @@ -182,7 +182,7 @@ import spotube.composeapp.generated.resources.settings_plugins_plugin_content_de private val OFFICIAL_PLUGIN_OWNERS = setOf("KRTirtho", "team-spotube") -private val VERIFIED_PLUGIN_OWNERS = setOf() +private val VERIFIED_PLUGIN_OWNERS = setOf("pgwiz", "sonic-liberation") @Composable fun PluginScreen( diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsScreen.kt index e79ebe0b..cf8d4453 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsScreen.kt @@ -51,6 +51,7 @@ import org.spotube.spotube.modules.settings.sections.languageRegionSection import org.spotube.spotube.modules.settings.sections.playbackSection import org.spotube.spotube.modules.settings.sections.pluginsSection import org.spotube.spotube.modules.settings.sections.supportSection +import org.spotube.spotube.modules.settings.sections.logsSection import org.spotube.spotube.modules.settings.sections.updatesSection import org.spotube.spotube.modules.shell.LocalAppShellBottomInset import org.jetbrains.compose.resources.stringResource @@ -161,6 +162,8 @@ private fun CompactSettingsContent( updatesSection( settings = settings, settingsViewModel = settingsViewModel) + logsSection( + navigatorCommands = navigatorCommands) } } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsSidebarLayout.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsSidebarLayout.kt index 3ff64bac..1c95f1e3 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsSidebarLayout.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsSidebarLayout.kt @@ -97,6 +97,8 @@ import spotube.composeapp.generated.resources.common_close import spotube.composeapp.generated.resources.settings_blacklist_title import spotube.composeapp.generated.resources.settings_screen_title import spotube.composeapp.generated.resources.settings_section_about +import spotube.composeapp.generated.resources.settings_section_logs +import org.spotube.spotube.modules.settings.logs.LogsContent import spotube.composeapp.generated.resources.settings_section_appearance import spotube.composeapp.generated.resources.settings_section_caching import spotube.composeapp.generated.resources.settings_section_desktop @@ -129,6 +131,7 @@ internal enum class SettingsTab( Desktop(Res.string.settings_section_desktop, Iconsax.IconsaxImportArrow2), Support(Res.string.settings_section_support, Iconsax.IconsaxHeart2), About(Res.string.settings_section_about, Iconsax.IconsaxInformation), + Logs(Res.string.settings_section_logs, Iconsax.IconsaxDocumentText), } /** @@ -283,6 +286,10 @@ private fun SettingsTabContent( JamSettingsContent(viewModel = koinViewModel()) } + SettingsTab.Logs -> { + LogsContent(modifier = Modifier.fillMaxSize()) + } + else -> { if (settings == null) { Box( diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/logs/LogsScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/logs/LogsScreen.kt new file mode 100644 index 00000000..23681b0b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/logs/LogsScreen.kt @@ -0,0 +1,460 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.settings.logs + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import co.touchlab.kermit.Severity +import com.composeunstyled.Text +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.koinInject +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumTextField +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.logger.AppLogBuffer +import org.spotube.spotube.core.logger.LogEntry +import org.spotube.spotube.core.server.LocalServer +import org.spotube.spotube.core.ui.component.ApplicationMainBar +import org.spotube.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.modules.plugin.PluginManager +import org.spotube.spotube.modules.shell.LocalAppShellBottomInset +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxDocumentText +import org.spotube.spotube.resources.iconsax.IconsaxFilterSearch +import org.spotube.spotube.resources.iconsax.IconsaxShare +import org.spotube.spotube.resources.iconsax.IconsaxTrash +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.settings_logs_clear +import spotube.composeapp.generated.resources.settings_logs_copied +import spotube.composeapp.generated.resources.settings_logs_copy +import spotube.composeapp.generated.resources.settings_logs_empty +import spotube.composeapp.generated.resources.settings_logs_filter_all +import spotube.composeapp.generated.resources.settings_logs_filter_errors +import spotube.composeapp.generated.resources.settings_logs_filter_warnings +import spotube.composeapp.generated.resources.settings_logs_search_hint +import spotube.composeapp.generated.resources.settings_logs_title + +private enum class LogFilter { + ALL, + ERRORS, + WARNINGS +} + +@Composable +fun LogsScreen() { + Column(modifier = Modifier.fillMaxSize()) { + ApplicationMainBar( + backButton = true, + title = { Text(stringResource(Res.string.settings_logs_title)) } + ) + LogsContent(modifier = Modifier.weight(1f)) + } +} + +@Composable +internal fun LogsContent(modifier: Modifier = Modifier) { + val logs by AppLogBuffer.logs.collectAsStateWithLifecycle() + val shellBottomInset = LocalAppShellBottomInset.current + val clipboardManager = LocalClipboardManager.current + val coroutineScope = rememberCoroutineScope() + + val localServer = runCatching { koinInject() }.getOrNull() + val localServerPort by (localServer?.port?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(null) }) + val pluginManager = runCatching { koinInject() }.getOrNull() + val audioPlugin by (pluginManager?.selectedAudioPlugin?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(null) }) + val audioPlayerQueue = runCatching { koinInject() }.getOrNull() + val currentQueueEntry by (audioPlayerQueue?.currentQueueEntryFlow?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(null) }) + + val searchState = rememberTextFieldState("") + val searchQuery = searchState.text.toString().trim().lowercase() + var selectedFilter by remember { mutableStateOf(LogFilter.ALL) } + var copiedFeedback by remember { mutableStateOf(false) } + + val filteredLogs = remember(logs, searchQuery, selectedFilter) { + logs.filter { entry -> + val matchesSeverity = when (selectedFilter) { + LogFilter.ALL -> true + LogFilter.ERRORS -> entry.severity == Severity.Error || entry.severity == Severity.Assert + LogFilter.WARNINGS -> entry.severity == Severity.Warn || entry.severity == Severity.Error || entry.severity == Severity.Assert + } + val matchesSearch = if (searchQuery.isBlank()) { + true + } else { + entry.tag.lowercase().contains(searchQuery) || entry.message.lowercase().contains(searchQuery) + } + matchesSeverity && matchesSearch + }.asReversed() + } + + val listState = rememberLazyListState() + + Column(modifier = modifier.fillMaxSize().padding(horizontal = 16.dp)) { + // Diagnostic status overview card + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + .clip(RoundedCornerShape(12.dp)) + .background(AppTheme.colors.surfaceVariant.copy(alpha = 0.5f)) + .padding(12.dp) + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = "System Diagnostics", + style = AppTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = AppTheme.colors.primary + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "Proxy Server:", + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.textMuted + ) + Text( + text = if (localServerPort != null) "127.0.0.1:$localServerPort (Active)" else "Starting...", + style = AppTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + color = if (localServerPort != null) AppTheme.colors.primary else AppTheme.colors.textMuted + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "Audio Plugin:", + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.textMuted + ) + Text( + text = audioPlugin?.name ?: "NewPipe YouTube (Built-in)", + style = AppTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + color = AppTheme.colors.onSurface + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "Current Track:", + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.textMuted + ) + Text( + text = currentQueueEntry?.let { entry -> + when (entry) { + is org.spotube.spotube.core.audioplayer.QueueEntry.StreamingTrack -> entry.track.title + is org.spotube.spotube.core.audioplayer.QueueEntry.LocalTrack -> entry.name + } + } ?: "None", + style = AppTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + color = AppTheme.colors.onSurface + ) + } + } + } + + // Action & Filter row + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + MillenniumTextField( + state = searchState, + placeholder = { Text(stringResource(Res.string.settings_logs_search_hint), fontSize = 12.sp) }, + leadingIcon = { + MillenniumIcon( + imageVector = Iconsax.IconsaxFilterSearch, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + }, + modifier = Modifier.weight(1f) + ) + + MillenniumButton( + onClick = { + val fullLogs = AppLogBuffer.formatAllLogs() + clipboardManager.setText(AnnotatedString(fullLogs)) + copiedFeedback = true + coroutineScope.launch { + delay(2000) + copiedFeedback = false + } + }, + style = MillenniumButtonStyle.Secondary + ) { + MillenniumIcon( + imageVector = Iconsax.IconsaxShare, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = if (copiedFeedback) stringResource(Res.string.settings_logs_copied) else stringResource(Res.string.settings_logs_copy), + style = AppTheme.typography.labelSmall + ) + } + + MillenniumIconButton( + onClick = { AppLogBuffer.clear() }, + style = MillenniumIconButtonStyle.Secondary, + contentDescription = stringResource(Res.string.settings_logs_clear) + ) { + MillenniumIcon( + imageVector = Iconsax.IconsaxTrash, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + } + } + + // Filter chips row + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + LogFilterChip( + label = stringResource(Res.string.settings_logs_filter_all), + selected = selectedFilter == LogFilter.ALL, + count = logs.size, + onClick = { selectedFilter = LogFilter.ALL } + ) + LogFilterChip( + label = stringResource(Res.string.settings_logs_filter_errors), + selected = selectedFilter == LogFilter.ERRORS, + count = logs.count { it.severity == Severity.Error || it.severity == Severity.Assert }, + chipColor = Color(0xFFE53935), + onClick = { selectedFilter = LogFilter.ERRORS } + ) + LogFilterChip( + label = stringResource(Res.string.settings_logs_filter_warnings), + selected = selectedFilter == LogFilter.WARNINGS, + count = logs.count { it.severity == Severity.Warn }, + chipColor = Color(0xFFFB8C00), + onClick = { selectedFilter = LogFilter.WARNINGS } + ) + } + + // Logs list + if (filteredLogs.isEmpty()) { + Box( + modifier = Modifier.weight(1f).fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + MillenniumIcon( + imageVector = Iconsax.IconsaxDocumentText, + contentDescription = null, + tint = AppTheme.colors.textMuted, + modifier = Modifier.size(36.dp) + ) + Text( + text = stringResource(Res.string.settings_logs_empty), + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.textMuted + ) + } + } + } else { + LazyColumn( + state = listState, + modifier = Modifier.weight(1f).fillMaxWidth(), + contentPadding = PaddingValues(top = 4.dp, bottom = 16.dp + shellBottomInset), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + items(filteredLogs, key = { it.id }) { logEntry -> + LogEntryCard(entry = logEntry) + } + } + } + } +} + +@Composable +private fun LogFilterChip( + label: String, + selected: Boolean, + count: Int, + chipColor: Color = AppTheme.colors.primary, + onClick: () -> Unit +) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background( + if (selected) chipColor.copy(alpha = 0.2f) else AppTheme.colors.surfaceVariant.copy(alpha = 0.3f) + ) + .clickable(onClick = onClick) + .padding(horizontal = 10.dp, vertical = 6.dp) + ) { + Text( + text = "$label ($count)", + style = AppTheme.typography.labelSmall, + fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal, + color = if (selected) chipColor else AppTheme.colors.onSurface + ) + } +} + +@Composable +private fun LogEntryCard(entry: LogEntry) { + var expanded by remember { mutableStateOf(false) } + + val severityColor = when (entry.severity) { + Severity.Error, Severity.Assert -> Color(0xFFE53935) + Severity.Warn -> Color(0xFFFB8C00) + Severity.Info -> AppTheme.colors.primary + Severity.Debug -> AppTheme.colors.textMuted + Severity.Verbose -> AppTheme.colors.textMuted.copy(alpha = 0.7f) + } + + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(AppTheme.colors.surfaceVariant.copy(alpha = 0.35f)) + .clickable(enabled = entry.throwable != null) { expanded = !expanded } + .padding(8.dp) + ) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + // Severity label + Box( + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(severityColor.copy(alpha = 0.18f)) + .padding(horizontal = 4.dp, vertical = 1.dp) + ) { + Text( + text = entry.severity.name.take(4), + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + color = severityColor + ) + } + + // Timestamp + Text( + text = entry.formattedTime, + fontSize = 11.sp, + fontFamily = FontFamily.Monospace, + color = AppTheme.colors.textMuted + ) + + // Tag + Text( + text = "[${entry.tag}]", + fontSize = 11.sp, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.SemiBold, + color = AppTheme.colors.onSurface + ) + } + + // Message + Text( + text = entry.message, + fontSize = 12.sp, + fontFamily = FontFamily.Monospace, + color = AppTheme.colors.onSurface, + lineHeight = 16.sp + ) + + // Throwable toggle / stack trace + if (entry.throwable != null) { + Text( + text = if (expanded) "▲ Hide stack trace" else "▼ Show stack trace (${entry.throwable::class.simpleName})", + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + color = severityColor + ) + AnimatedVisibility(visible = expanded) { + Text( + text = entry.throwable.stackTraceToString(), + fontSize = 10.sp, + fontFamily = FontFamily.Monospace, + color = AppTheme.colors.textMuted, + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp) + .clip(RoundedCornerShape(4.dp)) + .background(Color.Black.copy(alpha = 0.3f)) + .padding(6.dp) + ) + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/LogsSection.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/LogsSection.kt new file mode 100644 index 00000000..dc124e61 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/LogsSection.kt @@ -0,0 +1,56 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.settings.sections + +import androidx.compose.foundation.lazy.LazyListScope +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.modules.settings.components.SettingCardItem +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxDocumentText +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.settings_section_logs +import spotube.composeapp.generated.resources.settings_logs_title +import spotube.composeapp.generated.resources.settings_logs_subtitle + +internal fun LazyListScope.logsSection( + navigatorCommands: NavigationCommands, + includeHeader: Boolean = true +) { + if (includeHeader) { + settingsSectionHeader(Res.string.settings_section_logs) + } + settingsSectionCard( + items = listOf { + SettingCardItem( + title = stringResource(Res.string.settings_logs_title), + subtitle = stringResource(Res.string.settings_logs_subtitle), + icon = { + SettingsItemIcon( + imageVector = Iconsax.IconsaxDocumentText, + contentDescription = stringResource(Res.string.settings_logs_title) + ) + }, + onClick = { + navigatorCommands.navigateTo(Routes.Logs) + } + ) + } + ) +} diff --git a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueueTest.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueueTest.kt index 5dde1c65..bb3ad2b3 100644 --- a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueueTest.kt +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueueTest.kt @@ -494,4 +494,149 @@ class DeviceAudioPlayerQueueTest { assertEquals("Fallback", local.name) assertEquals(listOf("Artist1", "Artist2"), local.artists) } + + private suspend fun waitUntil(maxAttempts: Int = 100, condition: suspend () -> Boolean) { + var attempts = 0 + while (attempts < maxAttempts) { + if (condition()) return + withContext(Dispatchers.IO) { + Thread.sleep(25) + } + attempts++ + } + assertTrue { condition() } + } + + // --- endless playback --- + + @Test + fun `endless playback appends recommendations when queue finishes`() = runTest { + val recTrack = streamingTrack("rec1") + val service = FakePluginService( + metadataTrackAPI = FakeMetadataTrackAPI(recommendations = listOf(recTrack)) + ) + fakePlugin.setPlugin(service) + fakeSettings.setSettings( + org.spotube.spotube.modules.settings.UserSettings( + playbackProxyServerPort = 14769, + enableEndlessPlayback = true + ) + ) + + val queue = createQueue() + val seed = streamingEntry("t1") + queue.load(listOf(seed)) + + waitUntil { + queue.getQueue().any { (it as? QueueEntry.StreamingTrack)?.track?.id == "rec1" } + } + } + + @Test + fun `endless playback cascades to album tracks when recommendations empty`() = runTest { + val albumTrack = streamingTrack("album-track-1") + val service = FakePluginService( + metadataTrackAPI = FakeMetadataTrackAPI(recommendations = emptyList()), + metadataAlbumAPI = FakeMetadataAlbumAPI( + albumTracks = mapOf("album-t1" to listOf(streamingTrack("t1"), albumTrack)) + ) + ) + fakePlugin.setPlugin(service) + fakeSettings.setSettings( + org.spotube.spotube.modules.settings.UserSettings( + playbackProxyServerPort = 14769, + enableEndlessPlayback = true + ) + ) + + val queue = createQueue() + val seed = streamingEntry("t1") + queue.load(listOf(seed)) + + waitUntil { + queue.getQueue().any { (it as? QueueEntry.StreamingTrack)?.track?.id == "album-track-1" } + } + val queueTracks = queue.getQueue() + assertEquals(1, queueTracks.count { (it as? QueueEntry.StreamingTrack)?.track?.id == "t1" }) + } + + @Test + fun `endless playback cascades to artist top tracks when album tracks empty`() = runTest { + val artistTrack = streamingTrack("top-track-1") + val service = FakePluginService( + metadataTrackAPI = FakeMetadataTrackAPI(recommendations = emptyList()), + metadataAlbumAPI = FakeMetadataAlbumAPI(albumTracks = emptyMap()), + metadataArtistAPI = FakeMetadataArtistAPI( + topTracks = mapOf("artist-t1" to listOf(streamingTrack("t1"), artistTrack)) + ) + ) + fakePlugin.setPlugin(service) + fakeSettings.setSettings( + org.spotube.spotube.modules.settings.UserSettings( + playbackProxyServerPort = 14769, + enableEndlessPlayback = true + ) + ) + + val queue = createQueue() + val seed = streamingEntry("t1") + queue.load(listOf(seed)) + + waitUntil { + queue.getQueue().any { (it as? QueueEntry.StreamingTrack)?.track?.id == "top-track-1" } + } + val queueTracks = queue.getQueue() + assertEquals(1, queueTracks.count { (it as? QueueEntry.StreamingTrack)?.track?.id == "t1" }) + } + + @Test + fun `endless playback filters out blacklisted tracks`() = runTest { + val recTrack = streamingTrack("rec1") + fakeBlacklist.toggleTrack(recTrack) + + val service = FakePluginService( + metadataTrackAPI = FakeMetadataTrackAPI(recommendations = listOf(recTrack)) + ) + fakePlugin.setPlugin(service) + fakeSettings.setSettings( + org.spotube.spotube.modules.settings.UserSettings( + playbackProxyServerPort = 14769, + enableEndlessPlayback = true + ) + ) + + val queue = createQueue() + val seed = streamingEntry("t1") + queue.load(listOf(seed)) + + withContext(Dispatchers.IO) { Thread.sleep(200) } + + val queueTracks = queue.getQueue() + assertTrue { queueTracks.none { (it as? QueueEntry.StreamingTrack)?.track?.id == "rec1" } } + } + + @Test + fun `endless playback does not fetch when setting is disabled`() = runTest { + val recTrack = streamingTrack("rec1") + val service = FakePluginService( + metadataTrackAPI = FakeMetadataTrackAPI(recommendations = listOf(recTrack)) + ) + fakePlugin.setPlugin(service) + fakeSettings.setSettings( + org.spotube.spotube.modules.settings.UserSettings( + playbackProxyServerPort = 14769, + enableEndlessPlayback = false + ) + ) + + val queue = createQueue() + val seed = streamingEntry("t1") + queue.load(listOf(seed)) + + withContext(Dispatchers.IO) { Thread.sleep(200) } + + val queueTracks = queue.getQueue() + assertEquals(1, queueTracks.size) + assertEquals("t1", (queueTracks[0] as? QueueEntry.StreamingTrack)?.track?.id) + } } diff --git a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/FakePluginProvider.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/FakePluginProvider.kt index b3daab34..8a922a03 100644 --- a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/FakePluginProvider.kt +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/FakePluginProvider.kt @@ -1,14 +1,22 @@ package org.spotube.spotube.core.audioplayer +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI +import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistOverview import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI import org.spotube.spotube.core.zipline.PluginService import org.spotube.spotube.core.zipline.PluginServiceScope import org.spotube.spotube.modules.plugin.PluginProvider +import app.cash.zipline.ZiplineService import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlin.reflect.KClass class FakeMetadataTrackAPI( private val recommendations: List = emptyList() @@ -32,8 +40,44 @@ class FakeMetadataTrackAPI( ): List = recommendations } +class FakeMetadataAlbumAPI( + private val albumTracks: Map> = emptyMap() +) : MetadataAlbumAPI { + override suspend fun getAlbum(id: String): MetadataAlbum.Detailed = error("not mocked") + override suspend fun getTrackAlbum(track: MetadataTrack): MetadataAlbum.Detailed = error("not mocked") + override suspend fun getAlbumTracks(id: String, pagination: PaginationStrategy?): PaginationResult { + val tracks = albumTracks[id] ?: emptyList() + return PaginationResult( + items = tracks, + totalCount = tracks.size, + nextPagination = null + ) + } + override suspend fun savedAlbums(pagination: PaginationStrategy?): PaginationResult = error("not mocked") + override suspend fun isSavedAlbums(ids: List): List = error("not mocked") + override suspend fun saveAlbums(ids: List) = error("not mocked") + override suspend fun removeSavedAlbums(ids: List) = error("not mocked") +} + +class FakeMetadataArtistAPI( + private val topTracks: Map> = emptyMap() +) : MetadataArtistAPI { + override suspend fun getArtist(id: String): MetadataArtist.Detailed = error("not mocked") + override suspend fun artistOverview(id: String): MetadataArtistOverview = error("not mocked") + override suspend fun getArtistTop10Tracks(id: String): List = topTracks[id] ?: emptyList() + override suspend fun relatedArtists(id: String, pagination: PaginationStrategy?): PaginationResult = error("not mocked") + override suspend fun featuredPlaylists(id: String, pagination: PaginationStrategy?): PaginationResult = error("not mocked") + override suspend fun getArtistAlbums(id: String, pagination: PaginationStrategy?): PaginationResult = error("not mocked") + override suspend fun savedArtists(pagination: PaginationStrategy?): PaginationResult = error("not mocked") + override suspend fun isSavedArtists(ids: List): List = error("not mocked") + override suspend fun saveArtists(ids: List) = error("not mocked") + override suspend fun removeSavedArtists(ids: List) = error("not mocked") +} + class FakePluginService( private val metadataTrackAPI: MetadataTrackAPI = FakeMetadataTrackAPI(), + private val metadataAlbumAPI: MetadataAlbumAPI? = null, + private val metadataArtistAPI: MetadataArtistAPI? = null, override val pluginId: String = "fake-plugin") : PluginService { override val loggedInFlow: StateFlow = MutableStateFlow(true) @@ -41,9 +85,12 @@ class FakePluginService( override suspend fun stop() = Unit override suspend fun use(block: suspend PluginServiceScope.() -> T): T { - val scope = PluginServiceScope( - mapOf(MetadataTrackAPI::class to metadataTrackAPI) - ) + val registry = buildMap, ZiplineService> { + put(MetadataTrackAPI::class, metadataTrackAPI) + metadataAlbumAPI?.let { put(MetadataAlbumAPI::class, it) } + metadataArtistAPI?.let { put(MetadataArtistAPI::class, it) } + } + val scope = PluginServiceScope(registry) return scope.block() } } diff --git a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt index 28feb80c..0c3b555f 100644 --- a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt @@ -455,6 +455,23 @@ class StreamingUrlRepositoryBehaviorTest { assertEquals("https://example.com/fresh.opus", info?.url) assertEquals(1, plugin.audioAPI.getStreamsOfAudioSourceCallCount) 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 diff --git a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryFakes.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryFakes.kt index d8f1af5f..79570f4d 100644 --- a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryFakes.kt +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryFakes.kt @@ -68,6 +68,7 @@ class FakeTrackSourceRepository : TrackSourceRepository { private val store = mutableMapOf() var getCallCount = 0 var saveCallCount = 0 + var deleteCallCount = 0 override suspend fun getTrackSource(track: MetadataTrack, pluginId: String): AudioSource.Basic? { getCallCount++ @@ -81,6 +82,14 @@ class FakeTrackSourceRepository : TrackSourceRepository { 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 seed(track: MetadataTrack, source: AudioSource.Basic, pluginId: String) { diff --git a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.ios.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.ios.kt index 3c99747b..2827ceca 100644 --- a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.ios.kt +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.ios.kt @@ -62,6 +62,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface private val avPlayer: AVPlayer = AVPlayer() private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val mediaSession = IosMediaSession() private var disposed = false @@ -97,6 +98,13 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface private var lastTimeControlStatus: AVPlayerTimeControlStatus? = null 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( interval = CMTimeMake(1, 4), queue = null @@ -115,6 +123,11 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface // val range = loadedRanges.objectAtIndex(0u) // } } + mediaSession.update( + item = _currentMediaItem.value, + positionSeconds = seconds, + durationSeconds = _duration.value.inWholeMilliseconds / 1000.0, + isPlaying = avPlayer.timeControlStatus == AVPlayerTimeControlStatusPlaying) } } @@ -142,6 +155,12 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface if (avPlayer.currentItem == null) { _playerState.tryEmit(PlayerState.IDLE) } + + mediaSession.update( + item = _currentMediaItem.value, + positionSeconds = _position.value.inWholeMilliseconds / 1000.0, + durationSeconds = _duration.value.inWholeMilliseconds / 1000.0, + isPlaying = status == AVPlayerTimeControlStatusPlaying) } private fun resolveState(): PlayerState { @@ -159,17 +178,29 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface } actual override suspend fun play() { + mediaSession.activate() avPlayer.play() } actual override suspend fun pause() { avPlayer.pause() + mediaSession.update( + item = _currentMediaItem.value, + positionSeconds = _position.value.inWholeMilliseconds / 1000.0, + durationSeconds = _duration.value.inWholeMilliseconds / 1000.0, + isPlaying = false) } actual override suspend fun stop() { avPlayer.pause() avPlayer.seekToTime(CMTimeMake(0, 1)) _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) { @@ -220,9 +251,14 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface avPlayer.replaceCurrentItemWithPlayerItem(avItem) _currentMediaItem.tryEmit(item) + mediaSession.update( + item = item, + positionSeconds = 0.0, + durationSeconds = item.duration.inWholeMilliseconds / 1000.0, + isPlaying = false) if (autoPlay) { - avPlayer.play() + play() } } @@ -277,7 +313,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface if (avItem != null) { avPlayer.replaceCurrentItemWithPlayerItem(avItem) _currentMediaItem.tryEmit(nextItem) - avPlayer.play() + play() } } } @@ -293,7 +329,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface if (avItem != null) { avPlayer.replaceCurrentItemWithPlayerItem(avItem) _currentMediaItem.tryEmit(prevItem) - avPlayer.play() + play() } } } @@ -327,6 +363,8 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface disposed = true avPlayer.pause() avPlayer.replaceCurrentItemWithPlayerItem(null) + mediaSession.deactivate() + mediaSession.clear() currentPlaylist.clear() urlIndexMap.clear() diff --git a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/IosMediaSession.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/IosMediaSession.kt new file mode 100644 index 00000000..aac4e435 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/IosMediaSession.kt @@ -0,0 +1,201 @@ +/* + * 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 . + */ + +@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.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() + private val remoteCommandTokens = mutableListOf>() + 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 + + private var lastIsPlaying = false + + 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 + + runCatching { + audioSession.setCategory( + category = AVAudioSessionCategoryPlayback, + mode = AVAudioSessionModeDefault, + options = 0u, + error = null + ) + } + registerAudioSessionNotifications() + registerRemoteCommands() + } + + fun activate() { + // AVPlayer automatically activates the shared audio session on play. + } + + fun deactivate() { + // AVPlayer automatically manages session deactivation when paused or stopped. + } + + fun update( + item: MediaItem?, + positionSeconds: Double, + durationSeconds: Double, + isPlaying: Boolean + ) { + if (item == null) { + nowPlayingInfoCenter.nowPlayingInfo = null + return + } + + val info = mutableMapOf() + info[MPMediaItemPropertyTitle] = item.title + info[MPMediaItemPropertyArtist] = item.artist + item.album.takeIf { it.isNotBlank() }?.let { + info[MPMediaItemPropertyAlbumTitle] = it + } + if (durationSeconds.isFinite() && durationSeconds > 0) { + info[MPMediaItemPropertyPlaybackDuration] = durationSeconds + } + if (positionSeconds.isFinite() && positionSeconds >= 0) { + info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = positionSeconds + } + info[MPNowPlayingInfoPropertyPlaybackRate] = if (isPlaying) 1.0 else 0.0 + lastIsPlaying = isPlaying + nowPlayingInfoCenter.nowPlayingInfo = info + } + + fun clear() { + lastIsPlaying = false + 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)?.toLong()?.toULong() + if (type == platform.AVFAudio.AVAudioSessionInterruptionTypeBegan) { + onPause?.invoke() + } else if (type == platform.AVFAudio.AVAudioSessionInterruptionTypeEnded) { + activate() + val options = (notification?.userInfo?.get( + platform.AVFAudio.AVAudioSessionInterruptionOptionKey + ) as? Number)?.toLong()?.toULong() ?: 0uL + val shouldResume = (options and + platform.AVFAudio.AVAudioSessionInterruptionOptionShouldResume) != 0uL + 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.togglePlayPauseCommand to + remoteCommandCenter.togglePlayPauseCommand.addTargetWithHandler { + if (lastIsPlaying) { + onPause?.invoke() + } else { + onPlay?.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 + } + } +} diff --git a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.ios.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.ios.kt index 95eb42c1..75d46a2d 100644 --- a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.ios.kt +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.ios.kt @@ -86,4 +86,8 @@ actual class NewPipeService { ) } } + + actual suspend fun getRadioMix(videoId: String): List { + return emptyList() + } } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d8ec04eb..13138cd7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -75,7 +75,7 @@ appdirs = { module = "net.harawata:appdirs", version.ref = "appdirs" } cache4k = { module = "io.github.reactivecircus.cache4k:cache4k", version.ref = "cache4k" } androidx-car-app = { module = "androidx.car.app:app", version.ref = "carApp" } 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" } haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" } haze-materials = { module = "dev.chrisbanes.haze:haze-blur-materials", version.ref = "haze" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 5be30bbe..eef496bc 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.0-all.zip -networkTimeout=10000 +networkTimeout=600000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index 5663e50d..4a484863 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -4,6 +4,10 @@ CADisableMinimumFrameDurationOnPhone + UIBackgroundModes + + audio + CFBundleURLTypes