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" 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..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 @@ -257,21 +257,33 @@ 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) } } } + /** + * 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( @@ -281,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 d01b6580..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 @@ -145,6 +145,31 @@ 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) + } + + /** + * 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 new file mode 100644 index 00000000..901195ce --- /dev/null +++ b/app/src/test/java/com/lostf1sh/pixelplayeross/data/network/jellyfin/JellyfinResponseParserTest.kt @@ -0,0 +1,57 @@ +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"))) + } + + 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))) + } +}