Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions .github/workflows/pr-build.yml
Original file line number Diff line number Diff line change
@@ -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"
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<List<JSONObject>> {
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) }
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}
}

/**
* Get the tracks of a playlist. Non-audio children of a mixed-content playlist are skipped.
*/
suspend fun getPlaylistItems(playlistId: String): Result<List<JSONObject>> {
val cred = credentials ?: return Result.failure(Exception("No credentials"))
val params = mapOf(
Expand All @@ -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) }
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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)))
}
}