From 78de894a735cda698ef4d8ea012775fc936b3b49 Mon Sep 17 00:00:00 2001 From: lostf1sh Date: Mon, 17 Aug 2026 16:05:01 +0300 Subject: [PATCH 1/3] Stop dropping Jellyfin playlists without a media type The playlist request filtered server-side with MediaTypes=Audio. Since Jellyfin 10.10 playlists can hold mixed content, so audio playlists are frequently reported with an empty or Unknown MediaType and the filter dropped them, leaving the Playlists tab empty on an otherwise working Jellyfin connection. Drop the query parameter and reject only playlists that explicitly declare another media type, and log when the server reports no audio playlists at all. Fixes #89 --- CHANGELOG.md | 5 +++ .../data/jellyfin/JellyfinRepository.kt | 1 + .../network/jellyfin/JellyfinApiService.kt | 15 ++++++-- .../jellyfin/JellyfinResponseParser.kt | 13 +++++++ .../jellyfin/JellyfinResponseParserTest.kt | 35 +++++++++++++++++++ 5 files changed, 66 insertions(+), 3 deletions(-) create mode 100644 app/src/test/java/com/lostf1sh/pixelplayeross/data/network/jellyfin/JellyfinResponseParserTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 01abf993..4a153c89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to PixelPlayerOSS will be documented in this file. +## [Unreleased] + +### Fixed +- Jellyfin playlists no longer go missing on Jellyfin 10.10 and newer, where playlists can hold mixed content and audio playlists are often reported without a media type. + ## [0.3.0] - 2026-08-15 ### Added diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/jellyfin/JellyfinRepository.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/jellyfin/JellyfinRepository.kt index ef344100..9cb04839 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/jellyfin/JellyfinRepository.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/jellyfin/JellyfinRepository.kt @@ -249,6 +249,7 @@ class JellyfinRepository @Inject constructor( Timber.w("$TAG: Server returned empty playlists but we have $localCount locally. Aborting sync.") return@withContext Result.success(emptyList()) } + Timber.w("$TAG: Server reported no audio playlists for this user") } val entities = playlists.map { playlist -> diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/network/jellyfin/JellyfinApiService.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/network/jellyfin/JellyfinApiService.kt index 8f9989a1..2980fbed 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/network/jellyfin/JellyfinApiService.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/network/jellyfin/JellyfinApiService.kt @@ -257,18 +257,27 @@ class JellyfinApiService @Inject constructor( } } + /** + * Get the user's audio playlists. + * + * The request deliberately omits `MediaTypes=Audio`: since Jellyfin 10.10 playlists can hold + * mixed content, so audio playlists are frequently reported with an empty or `Unknown` + * MediaType and a server-side filter would drop them. Non-audio playlists are rejected + * client-side instead. + */ suspend fun getPlaylists(): Result> { val cred = credentials ?: return Result.failure(Exception("No credentials")) val params = mapOf( "IncludeItemTypes" to "Playlist", "Recursive" to "true", - "Fields" to "ChildCount", - "MediaTypes" to "Audio" + "Fields" to "ChildCount" ) return requestJson("/Users/${cred.userId}/Items", params).map { response -> val items = response.optJSONArray("Items") - (0 until (items?.length() ?: 0)).mapNotNull { items?.optJSONObject(it) } + (0 until (items?.length() ?: 0)) + .mapNotNull { items?.optJSONObject(it) } + .filter { JellyfinResponseParser.isAudioPlaylist(it) } } } diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/network/jellyfin/JellyfinResponseParser.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/network/jellyfin/JellyfinResponseParser.kt index d01b6580..0205af5c 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/network/jellyfin/JellyfinResponseParser.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/network/jellyfin/JellyfinResponseParser.kt @@ -145,6 +145,19 @@ object JellyfinResponseParser { return jsonArray.map { parsePlaylist(it) } } + /** + * Whether a playlist item can hold audio. + * + * Since Jellyfin 10.10 playlists can hold mixed content, so audio playlists are frequently + * reported with an empty or `Unknown` MediaType. Only playlists that explicitly declare + * another media type are rejected. + */ + fun isAudioPlaylist(json: JSONObject): Boolean { + val mediaType = json.optString("MediaType").takeIf { it.isNotBlank() } ?: return true + return mediaType.equals("Audio", ignoreCase = true) || + mediaType.equals("Unknown", ignoreCase = true) + } + private fun containerToMimeType(container: String?): String? { if (container.isNullOrBlank()) return null return when (container.lowercase()) { diff --git a/app/src/test/java/com/lostf1sh/pixelplayeross/data/network/jellyfin/JellyfinResponseParserTest.kt b/app/src/test/java/com/lostf1sh/pixelplayeross/data/network/jellyfin/JellyfinResponseParserTest.kt new file mode 100644 index 00000000..4740d880 --- /dev/null +++ b/app/src/test/java/com/lostf1sh/pixelplayeross/data/network/jellyfin/JellyfinResponseParserTest.kt @@ -0,0 +1,35 @@ +package com.lostf1sh.pixelplayeross.data.network.jellyfin + +import org.json.JSONObject +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class JellyfinResponseParserTest { + + private fun playlist(mediaType: String?) = JSONObject().apply { + put("Id", "playlist-1") + put("Name", "Roadtrip") + if (mediaType != null) put("MediaType", mediaType) + } + + @Test + fun `audio playlists are kept`() { + assertTrue(JellyfinResponseParser.isAudioPlaylist(playlist("Audio"))) + assertTrue(JellyfinResponseParser.isAudioPlaylist(playlist("audio"))) + } + + @Test + fun `mixed content playlists on Jellyfin 10 10 and newer are kept`() { + assertTrue(JellyfinResponseParser.isAudioPlaylist(playlist(null))) + assertTrue(JellyfinResponseParser.isAudioPlaylist(playlist(""))) + assertTrue(JellyfinResponseParser.isAudioPlaylist(playlist("Unknown"))) + } + + @Test + fun `playlists of another media type are rejected`() { + assertFalse(JellyfinResponseParser.isAudioPlaylist(playlist("Video"))) + assertFalse(JellyfinResponseParser.isAudioPlaylist(playlist("Photo"))) + assertFalse(JellyfinResponseParser.isAudioPlaylist(playlist("Book"))) + } +} From ff7476213e70e4a5faaa04b378676930b524cf0c Mon Sep 17 00:00:00 2001 From: lostf1sh Date: Mon, 17 Aug 2026 16:10:26 +0300 Subject: [PATCH 2/3] Build a test APK for every pull request Reviewers and issue reporters had no way to try a change short of building the app themselves or waiting for the change to land on main and ride the next nightly. Build signed split APKs on every pull request and attach them to the run, versioned with the PR number and commit so a tester can tell which build they are on. Fork pull requests get no repo secrets, so those fall back to the throwaway keystore the other workflows already use. --- .github/workflows/pr-build.yml | 116 +++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 .github/workflows/pr-build.yml diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml new file mode 100644 index 00000000..535ace27 --- /dev/null +++ b/.github/workflows/pr-build.yml @@ -0,0 +1,116 @@ +name: PR Build + +on: + pull_request: + branches: + - main + # Docs-only changes can't break the build or need testing. Workflow changes + # are deliberately not ignored so edits to this file are exercised. + paths-ignore: + - "**.md" + - "docs/**" + - "assets/**" + - "fastlane/**" + - "metadata/**" + - "LICENSE" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: pr-build-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Test APK + runs-on: blacksmith-4vcpu-ubuntu-2404 + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Compute PR version + id: version + run: | + BASE_VERSION="$(grep '^APP_VERSION_NAME=' gradle.properties | cut -d= -f2)" + PR_NUMBER="${{ github.event.pull_request.number || 'manual' }}" + SHORT_SHA="$(git rev-parse --short HEAD)" + echo "name=${BASE_VERSION}-pr.${PR_NUMBER}.${SHORT_SHA}" >> "$GITHUB_OUTPUT" + echo "short-sha=$SHORT_SHA" >> "$GITHUB_OUTPUT" + + - name: Set up JDK 21 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "21" + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v6.2.0 + + # The shared CI keystore lives in the CI_KEYSTORE_B64 + CI_KEYSTORE_PASSWORD + # repo secrets so every workflow signs with the same stable key and builds + # install over each other. Pull requests from forks get no secrets, so those + # fall back to a throwaway keystore and testers have to uninstall first. + - name: Set up CI keystore + env: + CI_KEYSTORE_B64: ${{ secrets.CI_KEYSTORE_B64 }} + CI_KEYSTORE_PASSWORD: ${{ secrets.CI_KEYSTORE_PASSWORD }} + run: | + if [ -n "$CI_KEYSTORE_B64" ] && [ -n "$CI_KEYSTORE_PASSWORD" ]; then + KEYSTORE_PASSWORD="$CI_KEYSTORE_PASSWORD" + printf '%s' "$CI_KEYSTORE_B64" | base64 -d > pixelplayeross-ci.jks + else + KEYSTORE_PASSWORD="$(openssl rand -hex 24)" + keytool -genkey -v -keystore pixelplayeross-ci.jks -alias pixelplayeross-ci-key -keyalg RSA -keysize 4096 -validity 10000 \ + -storepass "$KEYSTORE_PASSWORD" -keypass "$KEYSTORE_PASSWORD" \ + -dname "CN=PixelPlayerOSS CI Throwaway, OU=Dev, O=PixelPlayerOSS, L=World, S=World, C=US" + fi + { + echo "storeFile=pixelplayeross-ci.jks" + echo "storePassword=$KEYSTORE_PASSWORD" + echo "keyAlias=pixelplayeross-ci-key" + echo "keyPassword=$KEYSTORE_PASSWORD" + } > keystore.properties + + - name: Build PR release APKs + run: > + ./gradlew :app:assembleRelease + -Ppixelplayer.enableAbiSplits=true + -PAPP_VERSION_NAME=${{ steps.version.outputs.name }} + + - name: Verify and rename split APKs + run: | + BUILD_TOOLS_VERSION="$(ls "$ANDROID_HOME/build-tools" | sort -V | tail -n 1)" + mkdir -p pr-apks + for abi in arm64-v8a armeabi-v7a; do + apk="app/build/outputs/apk/release/app-$abi-release.apk" + "$ANDROID_HOME/build-tools/$BUILD_TOOLS_VERSION/aapt2" dump badging "$apk" >/dev/null + "$ANDROID_HOME/build-tools/$BUILD_TOOLS_VERSION/apksigner" verify --verbose "$apk" + cp "$apk" "pr-apks/PixelPlayerOSS-${{ steps.version.outputs.name }}-$abi.apk" + done + + - name: Upload PR APK artifacts + id: upload + uses: actions/upload-artifact@v7.0.1 + with: + name: PixelPlayerOSS-pr-${{ steps.version.outputs.short-sha }} + path: pr-apks/*.apk + if-no-files-found: error + compression-level: 0 + retention-days: 14 + + - name: Summarize how to install + run: | + { + echo "## Test APK" + echo + echo "Version \`${{ steps.version.outputs.name }}\` built from \`${{ steps.version.outputs.short-sha }}\`." + echo + echo "Download it from the [run artifacts](${{ steps.upload.outputs.artifact-url }}), unzip, and install" + echo "the \`arm64-v8a\` APK on any phone from the last several years (\`armeabi-v7a\` for older 32-bit devices)." + echo + echo "Artifacts expire after 14 days. Builds from forks are signed with a throwaway key, so" + echo "uninstall the existing app first if the installer complains about the signature." + } >> "$GITHUB_STEP_SUMMARY" From 45728565b1305564a5f5b34799992a10f2f6826d Mon Sep 17 00:00:00 2001 From: lostf1sh Date: Mon, 17 Aug 2026 16:15:47 +0300 Subject: [PATCH 3/3] Skip non-audio children of mixed Jellyfin playlists Playlists that survive the media-type filter can still hold mixed content, and their video children were parsed as songs into both the playlist and the unified library. Keep only children that declare themselves audio. Real media items always carry a concrete media type, so unlike playlist containers they can be filtered on it. --- .../network/jellyfin/JellyfinApiService.kt | 7 +++++- .../jellyfin/JellyfinResponseParser.kt | 12 ++++++++++ .../jellyfin/JellyfinResponseParserTest.kt | 22 +++++++++++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/network/jellyfin/JellyfinApiService.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/network/jellyfin/JellyfinApiService.kt index 2980fbed..e8565a42 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/network/jellyfin/JellyfinApiService.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/network/jellyfin/JellyfinApiService.kt @@ -281,6 +281,9 @@ class JellyfinApiService @Inject constructor( } } + /** + * Get the tracks of a playlist. Non-audio children of a mixed-content playlist are skipped. + */ suspend fun getPlaylistItems(playlistId: String): Result> { val cred = credentials ?: return Result.failure(Exception("No credentials")) val params = mapOf( @@ -290,7 +293,9 @@ class JellyfinApiService @Inject constructor( return requestJson("/Playlists/$playlistId/Items", params).map { response -> val items = response.optJSONArray("Items") - (0 until (items?.length() ?: 0)).mapNotNull { items?.optJSONObject(it) } + (0 until (items?.length() ?: 0)) + .mapNotNull { items?.optJSONObject(it) } + .filter { JellyfinResponseParser.isAudioItem(it) } } } diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/network/jellyfin/JellyfinResponseParser.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/network/jellyfin/JellyfinResponseParser.kt index 0205af5c..7cfc6913 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/network/jellyfin/JellyfinResponseParser.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/network/jellyfin/JellyfinResponseParser.kt @@ -158,6 +158,18 @@ object JellyfinResponseParser { mediaType.equals("Unknown", ignoreCase = true) } + /** + * Whether a playlist child is a track. + * + * Playlists kept by [isAudioPlaylist] may still hold mixed content, and every real media item + * carries a concrete MediaType, so anything that does not declare itself audio is skipped + * rather than persisted as a song. + */ + fun isAudioItem(json: JSONObject): Boolean { + return json.optString("MediaType").equals("Audio", ignoreCase = true) || + json.optString("Type").equals("Audio", ignoreCase = true) + } + private fun containerToMimeType(container: String?): String? { if (container.isNullOrBlank()) return null return when (container.lowercase()) { diff --git a/app/src/test/java/com/lostf1sh/pixelplayeross/data/network/jellyfin/JellyfinResponseParserTest.kt b/app/src/test/java/com/lostf1sh/pixelplayeross/data/network/jellyfin/JellyfinResponseParserTest.kt index 4740d880..901195ce 100644 --- a/app/src/test/java/com/lostf1sh/pixelplayeross/data/network/jellyfin/JellyfinResponseParserTest.kt +++ b/app/src/test/java/com/lostf1sh/pixelplayeross/data/network/jellyfin/JellyfinResponseParserTest.kt @@ -32,4 +32,26 @@ class JellyfinResponseParserTest { assertFalse(JellyfinResponseParser.isAudioPlaylist(playlist("Photo"))) assertFalse(JellyfinResponseParser.isAudioPlaylist(playlist("Book"))) } + + private fun item(type: String?, mediaType: String?) = JSONObject().apply { + put("Id", "item-1") + put("Name", "Track") + if (type != null) put("Type", type) + if (mediaType != null) put("MediaType", mediaType) + } + + @Test + fun `tracks are kept as playlist items`() { + assertTrue(JellyfinResponseParser.isAudioItem(item("Audio", "Audio"))) + assertTrue(JellyfinResponseParser.isAudioItem(item(null, "Audio"))) + assertTrue(JellyfinResponseParser.isAudioItem(item("Audio", null))) + } + + @Test + fun `non-audio children of a mixed playlist are skipped`() { + assertFalse(JellyfinResponseParser.isAudioItem(item("Episode", "Video"))) + assertFalse(JellyfinResponseParser.isAudioItem(item("Movie", "Video"))) + assertFalse(JellyfinResponseParser.isAudioItem(item("Photo", "Photo"))) + assertFalse(JellyfinResponseParser.isAudioItem(item(null, null))) + } }