From 139cf835078127bab115fc19c977424ea1e31db0 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Wed, 15 Jul 2026 05:24:34 +0200 Subject: [PATCH] feat(test): add Maestro end-to-end coverage --- .dockerignore | 7 + .github/workflows/e2e.yml | 399 ++++++++++ .maestro/config.yaml | 14 + .maestro/flows/01_auth.yaml | 33 + .maestro/flows/02_onboarding_home.yaml | 13 + .maestro/flows/03_library_browse.yaml | 22 + .maestro/flows/04_search.yaml | 24 + .maestro/flows/05_playback.yaml | 29 + .maestro/flows/06_downloads.yaml | 11 + .maestro/flows/07_profiles_settings.yaml | 62 ++ .maestro/flows/08_logout.yaml | 23 + .maestro/jellyfin-demo/Dockerfile | 56 ++ .maestro/jellyfin-demo/entrypoint.sh | 17 + .maestro/jellyfin-demo/seed.sh | 46 ++ .maestro/media-config.yaml | 12 + .maestro/media_flows/01_dv_uhd.yaml | 26 + .maestro/media_flows/02_dv_web.yaml | 26 + .maestro/media_flows/03_av1.yaml | 26 + .maestro/media_flows/04_h264_high10.yaml | 26 + .maestro/media_flows/05_h264_eac3.yaml | 27 + .maestro/media_flows/06_hevc_flac.yaml | 26 + .maestro/real_flows/01_media_catalog.yaml | 38 + .maestro/real_flows/02_music_browse.yaml | 54 ++ .../01_profile_switch_isolation.yaml | 66 ++ .../regression_flows/02_profile_teardown.yaml | 48 ++ .../regression_flows/03_tv_library_focus.yaml | 68 ++ .../regression_flows/04_tv_player_keys.yaml | 39 + .../05_tv_next_episode_back.yaml | 43 + .../06_playback_recovery.yaml | 32 + .../create_guest_jellyfin_profile.yaml | 43 + .maestro/subflows/onboard_jellyfin.yaml | 38 + .maestro/subflows/onboard_jellyfin_tv.yaml | 54 ++ .maestro/subflows/open_codec_sample.yaml | 26 + .maestro/subflows/show_codec_track_sheet.yaml | 21 + .maestro/subflows/switch_codec_tracks.yaml | 27 + .../subflows/switch_codec_tracks_deep.yaml | 40 + CONTRIBUTING.md | 28 + lib/main.dart | 4 +- lib/screens/media_detail/action_buttons.dart | 45 +- .../helpers/track_selection_helper.dart | 1 + scripts/ci_checks.sh | 1 + scripts/maestro_fixtures.py | 135 ++++ scripts/maestro_jellyfin_proxy.py | 194 +++++ scripts/maestro_real_jellyfin.py | 687 ++++++++++++++++ scripts/prepare_maestro_media.py | 141 ++++ scripts/run_maestro.py | 739 ++++++++++++++++++ scripts/test_maestro_jellyfin_proxy.py | 140 ++++ scripts/test_maestro_real_jellyfin.py | 159 ++++ scripts/test_run_maestro.py | 138 ++++ test/widgets/track_sheet_test.dart | 7 + 50 files changed, 3961 insertions(+), 20 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/e2e.yml create mode 100644 .maestro/config.yaml create mode 100644 .maestro/flows/01_auth.yaml create mode 100644 .maestro/flows/02_onboarding_home.yaml create mode 100644 .maestro/flows/03_library_browse.yaml create mode 100644 .maestro/flows/04_search.yaml create mode 100644 .maestro/flows/05_playback.yaml create mode 100644 .maestro/flows/06_downloads.yaml create mode 100644 .maestro/flows/07_profiles_settings.yaml create mode 100644 .maestro/flows/08_logout.yaml create mode 100644 .maestro/jellyfin-demo/Dockerfile create mode 100755 .maestro/jellyfin-demo/entrypoint.sh create mode 100755 .maestro/jellyfin-demo/seed.sh create mode 100644 .maestro/media-config.yaml create mode 100644 .maestro/media_flows/01_dv_uhd.yaml create mode 100644 .maestro/media_flows/02_dv_web.yaml create mode 100644 .maestro/media_flows/03_av1.yaml create mode 100644 .maestro/media_flows/04_h264_high10.yaml create mode 100644 .maestro/media_flows/05_h264_eac3.yaml create mode 100644 .maestro/media_flows/06_hevc_flac.yaml create mode 100644 .maestro/real_flows/01_media_catalog.yaml create mode 100644 .maestro/real_flows/02_music_browse.yaml create mode 100644 .maestro/regression_flows/01_profile_switch_isolation.yaml create mode 100644 .maestro/regression_flows/02_profile_teardown.yaml create mode 100644 .maestro/regression_flows/03_tv_library_focus.yaml create mode 100644 .maestro/regression_flows/04_tv_player_keys.yaml create mode 100644 .maestro/regression_flows/05_tv_next_episode_back.yaml create mode 100644 .maestro/regression_flows/06_playback_recovery.yaml create mode 100644 .maestro/subflows/create_guest_jellyfin_profile.yaml create mode 100644 .maestro/subflows/onboard_jellyfin.yaml create mode 100644 .maestro/subflows/onboard_jellyfin_tv.yaml create mode 100644 .maestro/subflows/open_codec_sample.yaml create mode 100644 .maestro/subflows/show_codec_track_sheet.yaml create mode 100644 .maestro/subflows/switch_codec_tracks.yaml create mode 100644 .maestro/subflows/switch_codec_tracks_deep.yaml create mode 100644 scripts/maestro_fixtures.py create mode 100755 scripts/maestro_jellyfin_proxy.py create mode 100755 scripts/maestro_real_jellyfin.py create mode 100755 scripts/prepare_maestro_media.py create mode 100755 scripts/run_maestro.py create mode 100755 scripts/test_maestro_jellyfin_proxy.py create mode 100755 scripts/test_maestro_real_jellyfin.py create mode 100755 scripts/test_run_maestro.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..f50a8474 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +** +!.maestro/ +!.maestro/jellyfin-demo/ +!.maestro/jellyfin-demo/** +!scripts/ +!scripts/maestro_fixtures.py +!scripts/maestro_real_jellyfin.py diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 00000000..29e5cbc9 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,399 @@ +name: E2E - Maestro + +on: + push: + branches: + - main + pull_request: + branches: + - main + workflow_dispatch: + +concurrency: + group: maestro-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + android-maestro: + name: Android basic, catalog, and codec flows + runs-on: ubuntu-latest + timeout-minutes: 65 + permissions: + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ + | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + flutter-version: "3.44.0" + cache: true + pub-cache: false + + - name: Cache Pub dependencies + uses: actions/cache@v4 + with: + path: ~/.pub-cache + key: ${{ runner.os }}-pub-v3-${{ hashFiles('**/pubspec.yaml', '**/pubspec.lock') }} + + - name: Cache Gradle + uses: actions/cache@v4 + with: + path: ~/.gradle/caches + key: ${{ runner.os }}-gradle-e2e-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle-e2e- + ${{ runner.os }}-gradle- + + - name: Install dependencies + run: flutter pub get + + - name: Install Maestro + run: | + curl -fsSL "https://get.maestro.mobile.dev" | bash + echo "$HOME/.maestro/bin" >> "$GITHUB_PATH" + + - name: Build debug APK + run: flutter build apk --debug + + - name: Build deterministic Jellyfin image + run: python3 scripts/run_maestro.py build-image + + - name: Run Maestro suites + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 35 + arch: x86_64 + profile: pixel_6 + disable-animations: true + emulator-options: >- + -no-window -gpu swiftshader_indirect -no-snapshot -noaudio + -no-boot-anim -camera-back none + script: | + python3 scripts/run_maestro.py basic --skip-build --skip-jellyfin-build + python3 scripts/run_maestro.py catalog \ + --skip-build --skip-jellyfin-build --device emulator-5554 + python3 scripts/run_maestro.py media \ + --skip-build --skip-jellyfin-build --device emulator-5554 + + - name: Upload Maestro diagnostics + if: always() + uses: actions/upload-artifact@v4 + with: + name: maestro-android-diagnostics + path: | + build/maestro + build/maestro-real-jellyfin + build/maestro-media + ~/.maestro/tests + if-no-files-found: ignore + retention-days: 7 + + android-legacy-playback: + name: Android 9 / API 28 Play Store playback + runs-on: ubuntu-latest + timeout-minutes: 35 + permissions: + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ + | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + flutter-version: "3.44.0" + cache: true + pub-cache: false + + - name: Cache Pub dependencies + uses: actions/cache@v4 + with: + path: ~/.pub-cache + key: ${{ runner.os }}-pub-v3-${{ hashFiles('**/pubspec.yaml', '**/pubspec.lock') }} + + - name: Cache Gradle + uses: actions/cache@v4 + with: + path: ~/.gradle/caches + key: ${{ runner.os }}-gradle-e2e-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle-e2e- + ${{ runner.os }}-gradle- + + - name: Install dependencies + run: flutter pub get + + - name: Install Maestro + run: | + curl -fsSL "https://get.maestro.mobile.dev" | bash + echo "$HOME/.maestro/bin" >> "$GITHUB_PATH" + + - name: Build debug APK + run: flutter build apk --debug + + - name: Build deterministic Jellyfin image + run: python3 scripts/run_maestro.py build-image + + - name: Run legacy playback flow + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 28 + target: google_apis_playstore + arch: x86 + profile: pixel_2 + disable-animations: true + emulator-options: >- + -no-window -gpu swiftshader_indirect -no-snapshot -noaudio + -no-boot-anim -camera-back none + script: | + python3 scripts/run_maestro.py basic \ + --skip-build --skip-jellyfin-build \ + --device emulator-5554 \ + --flow .maestro/flows/05_playback.yaml \ + --jellyfin-log build/maestro-legacy/jellyfin.log \ + --diagnostics-dir build/maestro-legacy/diagnostics + + - name: Upload legacy playback diagnostics + if: always() + uses: actions/upload-artifact@v4 + with: + name: maestro-android-legacy-diagnostics + path: | + build/maestro-legacy + ~/.maestro/tests + if-no-files-found: ignore + retention-days: 7 + + android-tv-regressions: + name: Android TV and D-pad regressions + runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ + | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + flutter-version: "3.44.0" + cache: true + pub-cache: false + + - name: Cache Pub dependencies + uses: actions/cache@v4 + with: + path: ~/.pub-cache + key: ${{ runner.os }}-pub-v3-${{ hashFiles('**/pubspec.yaml', '**/pubspec.lock') }} + + - name: Cache Gradle + uses: actions/cache@v4 + with: + path: ~/.gradle/caches + key: ${{ runner.os }}-gradle-e2e-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle-e2e- + ${{ runner.os }}-gradle- + + - name: Install dependencies + run: flutter pub get + + - name: Install Maestro + run: | + curl -fsSL "https://get.maestro.mobile.dev" | bash + echo "$HOME/.maestro/bin" >> "$GITHUB_PATH" + + - name: Build debug APK + run: flutter build apk --debug + + - name: Build deterministic Jellyfin image + run: python3 scripts/run_maestro.py build-image + + - name: Run TV regression flows + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 35 + arch: x86_64 + profile: pixel_6 + disable-animations: true + emulator-options: >- + -no-window -gpu swiftshader_indirect -no-snapshot -noaudio + -no-boot-anim -camera-back none + script: | + python3 scripts/run_maestro.py basic \ + --skip-build --skip-jellyfin-build \ + --device emulator-5554 \ + --flow .maestro/regression_flows/03_tv_library_focus.yaml \ + --jellyfin-log build/maestro-tv/library-focus.log \ + --diagnostics-dir build/maestro-tv/library-focus-diagnostics + python3 scripts/run_maestro.py basic \ + --skip-build --skip-jellyfin-build \ + --device emulator-5554 \ + --flow .maestro/regression_flows/04_tv_player_keys.yaml \ + --jellyfin-log build/maestro-tv/player-keys.log \ + --diagnostics-dir build/maestro-tv/player-keys-diagnostics + python3 scripts/run_maestro.py basic \ + --skip-build --skip-jellyfin-build \ + --device emulator-5554 \ + --flow .maestro/regression_flows/05_tv_next_episode_back.yaml \ + --jellyfin-log build/maestro-tv/next-episode.log \ + --diagnostics-dir build/maestro-tv/next-episode-diagnostics + + - name: Upload TV regression diagnostics + if: always() + uses: actions/upload-artifact@v4 + with: + name: maestro-android-tv-diagnostics + path: | + build/maestro-tv + ~/.maestro/tests + if-no-files-found: ignore + retention-days: 7 + + android-playback-recovery: + name: Android playback recovery + runs-on: ubuntu-latest + timeout-minutes: 40 + permissions: + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ + | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + flutter-version: "3.44.0" + cache: true + pub-cache: false + + - name: Cache Pub dependencies + uses: actions/cache@v4 + with: + path: ~/.pub-cache + key: ${{ runner.os }}-pub-v3-${{ hashFiles('**/pubspec.yaml', '**/pubspec.lock') }} + + - name: Cache Gradle + uses: actions/cache@v4 + with: + path: ~/.gradle/caches + key: ${{ runner.os }}-gradle-e2e-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle-e2e- + ${{ runner.os }}-gradle- + + - name: Install dependencies + run: flutter pub get + + - name: Install Maestro + run: | + curl -fsSL "https://get.maestro.mobile.dev" | bash + echo "$HOME/.maestro/bin" >> "$GITHUB_PATH" + + - name: Build debug APK + run: flutter build apk --debug + + - name: Build deterministic Jellyfin image + run: python3 scripts/run_maestro.py build-image + + - name: Run media recovery flows + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 35 + arch: x86_64 + profile: pixel_6 + disable-animations: true + emulator-options: >- + -no-window -gpu swiftshader_indirect -no-snapshot -noaudio + -no-boot-anim -camera-back none + script: | + python3 scripts/run_maestro.py basic \ + --skip-build --skip-jellyfin-build \ + --device emulator-5554 \ + --fault music-failure \ + --flow .maestro/real_flows/02_music_browse.yaml \ + --jellyfin-log build/maestro-recovery/music-jellyfin.log \ + --proxy-journal build/maestro-recovery/music-proxy-journal.jsonl \ + --diagnostics-dir build/maestro-recovery/music-diagnostics + python3 scripts/run_maestro.py basic \ + --skip-build --skip-jellyfin-build \ + --device emulator-5554 \ + --fault recovery \ + --flow .maestro/regression_flows/06_playback_recovery.yaml \ + --jellyfin-log build/maestro-recovery/jellyfin.log \ + --proxy-journal build/maestro-recovery/proxy-journal.jsonl \ + --diagnostics-dir build/maestro-recovery/diagnostics + + - name: Upload recovery diagnostics + if: always() + uses: actions/upload-artifact@v4 + with: + name: maestro-android-recovery-diagnostics + path: | + build/maestro-recovery + ~/.maestro/tests + if-no-files-found: ignore + retention-days: 7 diff --git a/.maestro/config.yaml b/.maestro/config.yaml new file mode 100644 index 00000000..56e2d4c6 --- /dev/null +++ b/.maestro/config.yaml @@ -0,0 +1,14 @@ +flows: + - "flows/*.yaml" +testOutputDir: "build/maestro" +executionOrder: + continueOnFailure: false + flowsOrder: + - Fresh install authentication choices + - Jellyfin onboarding reaches Home + - Browse library and open media details + - Search media and open a result + - Start and exit video playback + - Open empty downloads state + - Manage profiles and open settings + - Logout returns to authentication diff --git a/.maestro/flows/01_auth.yaml b/.maestro/flows/01_auth.yaml new file mode 100644 index 00000000..35226807 --- /dev/null +++ b/.maestro/flows/01_auth.yaml @@ -0,0 +1,33 @@ +appId: com.edde746.plezy +name: Fresh install authentication choices +tags: + - e2e + - auth +--- +- retry: + maxRetries: 1 + commands: + - launchApp: + clearState: true + permissions: + all: allow + - extendedWaitUntil: + visible: "(?s)^(?:Sign in with Plex|Wait).*" + timeout: 30000 + - tapOn: + text: "Wait" + optional: true + - extendedWaitUntil: + visible: "Sign in with Plex" + timeout: 30000 +- assertVisible: "Show QR Code" +- assertVisible: "Connect to Jellyfin" +- tapOn: "Connect to Jellyfin" +- extendedWaitUntil: + visible: "Add Jellyfin server" + timeout: 10000 +- assertVisible: "(?s)Server URLs.*" +- assertVisible: "Find server" +- hideKeyboard +- back +- assertVisible: "Sign in with Plex" diff --git a/.maestro/flows/02_onboarding_home.yaml b/.maestro/flows/02_onboarding_home.yaml new file mode 100644 index 00000000..a74d0d13 --- /dev/null +++ b/.maestro/flows/02_onboarding_home.yaml @@ -0,0 +1,13 @@ +appId: com.edde746.plezy +name: Jellyfin onboarding reaches Home +tags: + - e2e + - onboarding +--- +- runFlow: ../subflows/onboard_jellyfin.yaml +- assertVisible: "(?s)^Home.*" +- assertVisible: "(?s)^Recently Added.*" +- assertVisible: "(?s).*Zulu Zone 4.*" +- assertVisible: "(?s)^Libraries.*" +- assertVisible: "(?s)^Search.*" +- assertVisible: "(?s)^Downloads.*" diff --git a/.maestro/flows/03_library_browse.yaml b/.maestro/flows/03_library_browse.yaml new file mode 100644 index 00000000..580dbd0d --- /dev/null +++ b/.maestro/flows/03_library_browse.yaml @@ -0,0 +1,22 @@ +appId: com.edde746.plezy +name: Browse library and open media details +tags: + - e2e + - library +--- +- runFlow: ../subflows/onboard_jellyfin.yaml +- tapOn: "(?s)^Libraries.*" +- extendedWaitUntil: + visible: "Maestro Movies" + timeout: 15000 +- extendedWaitUntil: + visible: "(?s).*Zulu Zone 4$" + timeout: 15000 +- tapOn: "(?s).*Zulu Zone 4$" +- extendedWaitUntil: + visible: "Overview" + timeout: 15000 +- assertVisible: "A deterministic title for TV alphabet focus coverage." +- assertVisible: "Play" +- back +- assertVisible: "Maestro Movies" diff --git a/.maestro/flows/04_search.yaml b/.maestro/flows/04_search.yaml new file mode 100644 index 00000000..da7c8ed6 --- /dev/null +++ b/.maestro/flows/04_search.yaml @@ -0,0 +1,24 @@ +appId: com.edde746.plezy +name: Search media and open a result +tags: + - e2e + - search +--- +- runFlow: ../subflows/onboard_jellyfin.yaml +- tapOn: "(?s)^Search.*" +- extendedWaitUntil: + visible: "Search movies, shows, music..." + timeout: 10000 +- tapOn: "Search movies, shows, music..." +- inputText: "Maestro" +- hideKeyboard +- extendedWaitUntil: + visible: "Maestro Movie" + timeout: 15000 +- tapOn: "Maestro Movie" +- extendedWaitUntil: + visible: "Overview" + timeout: 15000 +- assertVisible: "A deterministic movie used to verify Plezy's end-to-end flows." +- back +- assertVisible: "(?s)^Maestro Movie.*" diff --git a/.maestro/flows/05_playback.yaml b/.maestro/flows/05_playback.yaml new file mode 100644 index 00000000..8b82622d --- /dev/null +++ b/.maestro/flows/05_playback.yaml @@ -0,0 +1,29 @@ +appId: com.edde746.plezy +name: Start and exit video playback +tags: + - e2e + - playback +--- +- runFlow: ../subflows/onboard_jellyfin.yaml +- tapOn: "(?s)^Libraries.*" +- extendedWaitUntil: + visible: "(?s).*Zulu Zone 4$" + timeout: 15000 +- tapOn: "(?s).*Zulu Zone 4$" +- extendedWaitUntil: + visible: "Play" + timeout: 15000 +- tapOn: + text: "Play" + waitToSettleTimeoutMs: 1000 +- extendedWaitUntil: + visible: "Pause" + timeout: 20000 +- back +- extendedWaitUntil: + notVisible: "Pause" + timeout: 10000 +- back +- extendedWaitUntil: + visible: "Overview" + timeout: 10000 diff --git a/.maestro/flows/06_downloads.yaml b/.maestro/flows/06_downloads.yaml new file mode 100644 index 00000000..e32ac48e --- /dev/null +++ b/.maestro/flows/06_downloads.yaml @@ -0,0 +1,11 @@ +appId: com.edde746.plezy +name: Open empty downloads state +tags: + - e2e + - downloads +--- +- runFlow: ../subflows/onboard_jellyfin.yaml +- tapOn: "(?s)^Downloads.*" +- extendedWaitUntil: + visible: "No downloads" + timeout: 15000 diff --git a/.maestro/flows/07_profiles_settings.yaml b/.maestro/flows/07_profiles_settings.yaml new file mode 100644 index 00000000..661e8aa5 --- /dev/null +++ b/.maestro/flows/07_profiles_settings.yaml @@ -0,0 +1,62 @@ +appId: com.edde746.plezy +name: Manage profiles and open settings +tags: + - e2e + - profiles + - settings +--- +- runFlow: ../subflows/onboard_jellyfin.yaml +- tapOn: "M" +- tapOn: + text: "Profiles" + above: + text: "Settings" +- extendedWaitUntil: + visible: "Switch Profile" + timeout: 10000 +- assertVisible: "(?s).*Maestro.*" +- tapOn: "Add Plezy profile" +- extendedWaitUntil: + visible: "New profile" + timeout: 10000 +- tapOn: "e.g. Guests, Kids, Family Room" +- inputText: "E2E Guest" +- hideKeyboard +- tapOn: "Continue" +- extendedWaitUntil: + visible: "Add to E2E Guest" + timeout: 10000 +- assertVisible: "(?s)^Sign in with Plex.*" +- assertVisible: "(?s)^Connect to Jellyfin.*" +- back +- assertVisible: "(?s).*E2E Guest.*" +- back +- extendedWaitUntil: + visible: "(?s)^Home.*" + timeout: 10000 +- waitForAnimationToEnd: + timeout: 5000 +- repeat: + times: 2 + while: + notVisible: "Settings" + commands: + - tapOn: "M" + - waitForAnimationToEnd: + timeout: 3000 +- tapOn: + text: "Settings" + below: + text: "Profiles" +- extendedWaitUntil: + visible: "(?s)^Appearance.*" + timeout: 10000 +- assertVisible: "(?s)^Video Playback.*" +- assertVisible: "(?s)^Connections.*" +- tapOn: "(?s)^Appearance.*" +- extendedWaitUntil: + visible: "(?s)^Theme.*" + timeout: 10000 +- assertVisible: "(?s).*Library Density.*" +- back +- assertVisible: "(?s)^Appearance.*" diff --git a/.maestro/flows/08_logout.yaml b/.maestro/flows/08_logout.yaml new file mode 100644 index 00000000..f118f7bd --- /dev/null +++ b/.maestro/flows/08_logout.yaml @@ -0,0 +1,23 @@ +appId: com.edde746.plezy +name: Logout returns to authentication +tags: + - e2e + - auth +--- +- runFlow: ../subflows/onboard_jellyfin.yaml +- tapOn: "M" +- tapOn: + text: "Logout" + below: + text: "Settings" +- extendedWaitUntil: + visible: "Are you sure you want to logout?" + timeout: 10000 +- tapOn: + text: "Logout" + below: + text: "Are you sure you want to logout?" +- extendedWaitUntil: + visible: "Sign in with Plex" + timeout: 20000 +- assertVisible: "Connect to Jellyfin" diff --git a/.maestro/jellyfin-demo/Dockerfile b/.maestro/jellyfin-demo/Dockerfile new file mode 100644 index 00000000..4d88a360 --- /dev/null +++ b/.maestro/jellyfin-demo/Dockerfile @@ -0,0 +1,56 @@ +# syntax=docker/dockerfile:1.7 + +ARG JELLYFIN_IMAGE=jellyfin/jellyfin:10.11.11@sha256:aefb67e6a7ff1debdd154a78a7bbb780fd0c873d8639210a7f6a2016ad2b35db + +FROM ${JELLYFIN_IMAGE} AS seed + +USER root +RUN apt-get update \ + && apt-get install --yes --no-install-recommends ca-certificates python3 \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /opt/plezy-demo +COPY scripts/maestro_fixtures.py scripts/maestro_real_jellyfin.py scripts/prepare_maestro_media.py ./ +COPY .maestro/jellyfin-demo/seed.sh ./seed.sh + +ARG PLEZY_DEMO_MEDIA_BASE_URL=https://demo-files.plezy.app/media-samples/ +ARG PLEZY_DEMO_MEDIA_REVISION=2026-07-14 +ARG PLEZY_DEMO_MEDIA_DURATION=300 +RUN test -n "${PLEZY_DEMO_MEDIA_REVISION}" \ + && python3 maestro_real_jellyfin.py download-codecs \ + --base-url "${PLEZY_DEMO_MEDIA_BASE_URL}" \ + --output-dir /tmp/plezy-codecs \ + && PATH="/usr/lib/jellyfin-ffmpeg:${PATH}" python3 prepare_maestro_media.py \ + /tmp/plezy-codecs /tmp/plezy-codecs-prepared \ + --duration "${PLEZY_DEMO_MEDIA_DURATION}" \ + --extend av1_opus_ass_srt.mkv \ + --extend h264_eac3_multisub.mkv \ + --extend hevc10_flac_ass.mkv \ + && python3 maestro_real_jellyfin.py prepare \ + --output-dir /media \ + --codec-source-dir /tmp/plezy-codecs-prepared \ + --include-codecs \ + && rm -rf /tmp/plezy-codecs /tmp/plezy-codecs-prepared __pycache__ \ + && chmod -R a=rX /media + +RUN ./seed.sh + +FROM ${JELLYFIN_IMAGE} + +LABEL org.opencontainers.image.title="Plezy Jellyfin demo server" \ + org.opencontainers.image.description="Ready-to-run Jellyfin server with Plezy's deterministic codec demo catalog" \ + org.opencontainers.image.source="https://github.com/edde746/plezy" + +COPY --from=seed /media /media +COPY --from=seed /opt/plezy-demo/seed-config /opt/plezy-demo/seed-config +COPY .maestro/jellyfin-demo/entrypoint.sh /usr/local/bin/plezy-demo-entrypoint + +RUN chmod 0755 /usr/local/bin/plezy-demo-entrypoint \ + && chmod -R a=rX /media /opt/plezy-demo/seed-config + +ENV TZ=UTC \ + JELLYFIN_PublishedServerUrl=http://localhost:8096 + +EXPOSE 8096 +STOPSIGNAL SIGTERM +ENTRYPOINT ["/usr/local/bin/plezy-demo-entrypoint"] diff --git a/.maestro/jellyfin-demo/entrypoint.sh b/.maestro/jellyfin-demo/entrypoint.sh new file mode 100755 index 00000000..f3e902ee --- /dev/null +++ b/.maestro/jellyfin-demo/entrypoint.sh @@ -0,0 +1,17 @@ +#!/bin/sh +set -eu + +SEED_CONFIG=/opt/plezy-demo/seed-config +SEED_MARKER=.plezy-demo-seed + +if [ ! -f "/config/${SEED_MARKER}" ]; then + existing="$(find /config -mindepth 1 -maxdepth 1 -print -quit)" + if [ -n "${existing}" ]; then + echo "Refusing to overwrite a non-demo Jellyfin configuration in /config." >&2 + echo "Start this image with a new or empty /config volume." >&2 + exit 1 + fi + cp -a "${SEED_CONFIG}/." /config/ +fi + +exec /jellyfin/jellyfin "$@" diff --git a/.maestro/jellyfin-demo/seed.sh b/.maestro/jellyfin-demo/seed.sh new file mode 100755 index 00000000..aac87c25 --- /dev/null +++ b/.maestro/jellyfin-demo/seed.sh @@ -0,0 +1,46 @@ +#!/bin/sh +set -eu + +SEED_ROOT=/opt/plezy-demo +SEED_CONFIG="${SEED_ROOT}/seed-config" +SEED_CACHE="${SEED_ROOT}/seed-cache" +JELLYFIN_PID="" + +stop_jellyfin() { + if [ -n "${JELLYFIN_PID}" ] && kill -0 "${JELLYFIN_PID}" 2>/dev/null; then + kill -TERM "${JELLYFIN_PID}" + wait "${JELLYFIN_PID}" || true + fi +} + +on_exit() { + exit_status=$? + if [ "${exit_status}" -ne 0 ] && [ -f "${SEED_ROOT}/seed-jellyfin.log" ]; then + cat "${SEED_ROOT}/seed-jellyfin.log" >&2 + fi + stop_jellyfin +} +trap on_exit EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +mkdir -p "${SEED_CONFIG}/config" "${SEED_CONFIG}/log" "${SEED_CACHE}" +JELLYFIN_DATA_DIR="${SEED_CONFIG}" \ +JELLYFIN_CONFIG_DIR="${SEED_CONFIG}/config" \ +JELLYFIN_LOG_DIR="${SEED_CONFIG}/log" \ +JELLYFIN_CACHE_DIR="${SEED_CACHE}" \ +XDG_CACHE_HOME="${SEED_CACHE}" \ +JELLYFIN_PublishedServerUrl="http://127.0.0.1:8096" \ + /jellyfin/jellyfin >"${SEED_ROOT}/seed-jellyfin.log" 2>&1 & +JELLYFIN_PID=$! + +python3 "${SEED_ROOT}/maestro_real_jellyfin.py" bootstrap \ + --url http://127.0.0.1:8096 \ + --timeout 180 \ + --include-codecs + +stop_jellyfin +JELLYFIN_PID="" +rm -rf "${SEED_CONFIG}/log" "${SEED_CACHE}" "${SEED_ROOT}/seed-jellyfin.log" +printf '%s\n' 'Plezy Jellyfin demo seed v1' >"${SEED_CONFIG}/.plezy-demo-seed" +trap - EXIT INT TERM diff --git a/.maestro/media-config.yaml b/.maestro/media-config.yaml new file mode 100644 index 00000000..361b2257 --- /dev/null +++ b/.maestro/media-config.yaml @@ -0,0 +1,12 @@ +flows: + - "*.yaml" +testOutputDir: "build/maestro-media" +executionOrder: + continueOnFailure: false + flowsOrder: + - Codec sample - UHD Dolby Vision TrueHD and PGS + - Codec sample - Web Dolby Vision EAC3 Atmos and SRT + - Codec sample - AV1 Opus ASS and SRT + - Codec sample - H264 High10 DTS-HD and ASS + - Codec sample - H264 EAC3 multilingual SRT + - Codec sample - HEVC10 FLAC and ASS diff --git a/.maestro/media_flows/01_dv_uhd.yaml b/.maestro/media_flows/01_dv_uhd.yaml new file mode 100644 index 00000000..46b7a301 --- /dev/null +++ b/.maestro/media_flows/01_dv_uhd.yaml @@ -0,0 +1,26 @@ +appId: com.edde746.plezy +name: Codec sample - UHD Dolby Vision TrueHD and PGS +tags: + - e2e + - media + - dolby-vision +--- +- runFlow: + file: ../subflows/open_codec_sample.yaml + env: + SAMPLE_TITLE: "Codec DV UHD TrueHD PGS" + SAMPLE_OVERVIEW: "Dolby Vision profile 8 with an HDR fallback, TrueHD Atmos, AC-3, and PGS subtitles." +- runFlow: + file: ../subflows/switch_codec_tracks.yaml + env: + AUDIO_TRACK: "(?is)^English.*(?:AC3|TrueHD|Surround|5\\.1|7\\.1).*" + SUBTITLE_TRACK: "(?is)^Spanish.*PGS.*" +- repeat: + times: 2 + while: + notVisible: "Overview" + commands: + - back +- extendedWaitUntil: + visible: "Overview" + timeout: 10000 diff --git a/.maestro/media_flows/02_dv_web.yaml b/.maestro/media_flows/02_dv_web.yaml new file mode 100644 index 00000000..77d6d68e --- /dev/null +++ b/.maestro/media_flows/02_dv_web.yaml @@ -0,0 +1,26 @@ +appId: com.edde746.plezy +name: Codec sample - Web Dolby Vision EAC3 Atmos and SRT +tags: + - e2e + - media + - dolby-vision +--- +- runFlow: + file: ../subflows/open_codec_sample.yaml + env: + SAMPLE_TITLE: "Codec DV Web EAC3 SRT" + SAMPLE_OVERVIEW: "Dolby Vision profile 8 with an HDR fallback, E-AC-3 Atmos, and multilingual SRT subtitles." +- runFlow: + file: ../subflows/switch_codec_tracks.yaml + env: + AUDIO_TRACK: "(?is)^German.*(?:E-AC3|Dolby Digital Plus|Surround|5\\.1).*" + SUBTITLE_TRACK: "(?is)^German.*(?:SRT|APPLICATION/X-SUBRIP).*" +- repeat: + times: 2 + while: + notVisible: "Overview" + commands: + - back +- extendedWaitUntil: + visible: "Overview" + timeout: 10000 diff --git a/.maestro/media_flows/03_av1.yaml b/.maestro/media_flows/03_av1.yaml new file mode 100644 index 00000000..b1218679 --- /dev/null +++ b/.maestro/media_flows/03_av1.yaml @@ -0,0 +1,26 @@ +appId: com.edde746.plezy +name: Codec sample - AV1 Opus ASS and SRT +tags: + - e2e + - media + - av1 +--- +- runFlow: + file: ../subflows/open_codec_sample.yaml + env: + SAMPLE_TITLE: "Codec AV1 Opus ASS" + SAMPLE_OVERVIEW: "AV1 video with multilingual Opus audio, styled ASS subtitles, SRT subtitles, and embedded fonts." +- runFlow: + file: ../subflows/switch_codec_tracks.yaml + env: + AUDIO_TRACK: "(?is)^English.*(?:Opus|Stereo).*" + SUBTITLE_TRACK: "(?is)^English.*(?:ASS|TEXT/X-SSA).*" +- repeat: + times: 2 + while: + notVisible: "Overview" + commands: + - back +- extendedWaitUntil: + visible: "Overview" + timeout: 10000 diff --git a/.maestro/media_flows/04_h264_high10.yaml b/.maestro/media_flows/04_h264_high10.yaml new file mode 100644 index 00000000..047e19d7 --- /dev/null +++ b/.maestro/media_flows/04_h264_high10.yaml @@ -0,0 +1,26 @@ +appId: com.edde746.plezy +name: Codec sample - H264 High10 DTS-HD and ASS +tags: + - e2e + - media + - h264-high10 +--- +- runFlow: + file: ../subflows/open_codec_sample.yaml + env: + SAMPLE_TITLE: "Codec H264 High10 DTS-HD ASS" + SAMPLE_OVERVIEW: "H.264 High 10 video with dual DTS-HD MA audio, styled ASS subtitles, and embedded fonts." +- runFlow: + file: ../subflows/switch_codec_tracks.yaml + env: + AUDIO_TRACK: "(?is)^Japanese.*(?:DTS-HD MA|DTS|Surround|5\\.1).*" + SUBTITLE_TRACK: "(?is)^English.*(?:ASS|TEXT/X-SSA).*" +- repeat: + times: 2 + while: + notVisible: "Overview" + commands: + - back +- extendedWaitUntil: + visible: "Overview" + timeout: 10000 diff --git a/.maestro/media_flows/05_h264_eac3.yaml b/.maestro/media_flows/05_h264_eac3.yaml new file mode 100644 index 00000000..7dac1944 --- /dev/null +++ b/.maestro/media_flows/05_h264_eac3.yaml @@ -0,0 +1,27 @@ +appId: com.edde746.plezy +name: Codec sample - H264 EAC3 multilingual SRT +tags: + - e2e + - media + - subtitles +--- +- runFlow: + file: ../subflows/open_codec_sample.yaml + env: + SAMPLE_TITLE: "Codec H264 EAC3 Multisub" + SAMPLE_OVERVIEW: "H.264 video with three E-AC-3 audio tracks and a broad multilingual SRT subtitle set." +- runFlow: + file: ../subflows/switch_codec_tracks_deep.yaml + env: + AUDIO_TRACK: "(?is)^English.*(?:Dolby Digital Plus|E-AC3|Surround|5\\.1).*" + SUBTITLE_TRACK: "(?is)^English.*APPLICATION/X-SUBRIP.*" + SHEET_MARKER: "(?is)^Japanese.*(?:Dolby Digital Plus|E-AC3|Surround|5\\.1).*" +- repeat: + times: 2 + while: + notVisible: "Overview" + commands: + - back +- extendedWaitUntil: + visible: "Overview" + timeout: 10000 diff --git a/.maestro/media_flows/06_hevc_flac.yaml b/.maestro/media_flows/06_hevc_flac.yaml new file mode 100644 index 00000000..2aac99b1 --- /dev/null +++ b/.maestro/media_flows/06_hevc_flac.yaml @@ -0,0 +1,26 @@ +appId: com.edde746.plezy +name: Codec sample - HEVC10 FLAC and ASS +tags: + - e2e + - media + - hevc +--- +- runFlow: + file: ../subflows/open_codec_sample.yaml + env: + SAMPLE_TITLE: "Codec HEVC10 FLAC ASS" + SAMPLE_OVERVIEW: "HEVC Main 10 video with dual FLAC 5.1 audio, styled ASS subtitles, and embedded fonts." +- runFlow: + file: ../subflows/switch_codec_tracks.yaml + env: + AUDIO_TRACK: "(?is)^Japanese.*(?:FLAC|Surround|5\\.1).*" + SUBTITLE_TRACK: "(?is)^English.*(?:ASS|TEXT/X-SSA).*" +- repeat: + times: 2 + while: + notVisible: "Overview" + commands: + - back +- extendedWaitUntil: + visible: "Overview" + timeout: 10000 diff --git a/.maestro/real_flows/01_media_catalog.yaml b/.maestro/real_flows/01_media_catalog.yaml new file mode 100644 index 00000000..fb5934bb --- /dev/null +++ b/.maestro/real_flows/01_media_catalog.yaml @@ -0,0 +1,38 @@ +appId: com.edde746.plezy +name: Real Jellyfin imports and opens codec media +tags: + - e2e + - real-jellyfin + - media +--- +- runFlow: ../subflows/onboard_jellyfin.yaml +- tapOn: "(?s)^Search.*" +- extendedWaitUntil: + visible: "Search movies, shows, music..." + timeout: 10000 +- tapOn: "Search movies, shows, music..." +- inputText: "Codec DV UHD" +- hideKeyboard +- extendedWaitUntil: + visible: "(?s).*Codec DV UHD TrueHD PGS$" + timeout: 15000 +- tapOn: "(?s).*Codec DV UHD TrueHD PGS$" +- extendedWaitUntil: + visible: "Overview" + timeout: 15000 +- assertVisible: "Dolby Vision profile 8 with an HDR fallback, TrueHD Atmos, AC-3, and PGS subtitles." +- tapOn: + text: "Play" + waitToSettleTimeoutMs: 1000 +- extendedWaitUntil: + notVisible: "Overview" + timeout: 15000 +- back +- runFlow: + when: + notVisible: "Overview" + commands: + - back +- extendedWaitUntil: + visible: "Overview" + timeout: 10000 diff --git a/.maestro/real_flows/02_music_browse.yaml b/.maestro/real_flows/02_music_browse.yaml new file mode 100644 index 00000000..6059ce89 --- /dev/null +++ b/.maestro/real_flows/02_music_browse.yaml @@ -0,0 +1,54 @@ +appId: com.edde746.plezy +name: Jellyfin music browsing survives grouping reloads + +tags: + - e2e + - real-jellyfin + - music + - regression +--- +- runFlow: ../subflows/onboard_jellyfin.yaml +- assertVisible: "(?s)^Latest Albums in Maestro Music.*" +- assertVisible: "(?s)^Regression Album.*" +- tapOn: "(?s)^Libraries.*" +- extendedWaitUntil: + visible: "Maestro Movies" + timeout: 15000 +- tapOn: "Maestro Movies" +- extendedWaitUntil: + visible: "Maestro Music" + timeout: 10000 +- tapOn: "Maestro Music" +- extendedWaitUntil: + visible: "(?s)^Regression Album.*" + timeout: 15000 +- assertVisible: "(?s)^Latest Albums in Maestro Music.*" +- tapOn: "Browse" +- runFlow: + when: + visible: "Retry" + commands: + - tapOn: "Retry" +- extendedWaitUntil: + visible: "Maestro Artist" + timeout: 15000 +- assertNotVisible: "(?i).*connection timeout.*" +- tapOn: "Library options" +- tapOn: "(?s)^Grouping.*Artists$" +- tapOn: "Albums" +- extendedWaitUntil: + visible: "(?s)^Regression Album.*" + timeout: 15000 +- tapOn: "Library options" +- tapOn: "(?s)^Grouping.*Albums$" +- tapOn: "Tracks" +- extendedWaitUntil: + visible: "(?s)^Resilient Track.*" + timeout: 15000 +- tapOn: "Library options" +- tapOn: "(?s)^Grouping.*Tracks$" +- tapOn: "Folders" +- extendedWaitUntil: + visible: "Maestro Artist" + timeout: 15000 +- assertNotVisible: "(?i).*connection timeout.*" diff --git a/.maestro/regression_flows/01_profile_switch_isolation.yaml b/.maestro/regression_flows/01_profile_switch_isolation.yaml new file mode 100644 index 00000000..c7eb06f1 --- /dev/null +++ b/.maestro/regression_flows/01_profile_switch_isolation.yaml @@ -0,0 +1,66 @@ +appId: com.edde746.plezy +name: Profile switching isolates Jellyfin content and libraries + +tags: + - e2e + - regression + - profiles +--- +- runFlow: ../subflows/onboard_jellyfin.yaml +- assertVisible: "(?s).*Zulu Zone 4.*" +- tapOn: "(?s)^Libraries.*" +- extendedWaitUntil: + visible: "Maestro Movies" + timeout: 15000 +- assertNotVisible: "Guest Movies" +- tapOn: "(?s)^Home.*" +- runFlow: ../subflows/create_guest_jellyfin_profile.yaml +- tapOn: "(?s)^E\nE2E Guest\n.*" +- extendedWaitUntil: + visible: "(?s).*Guest Galaxy$" + timeout: 30000 +- assertNotVisible: "(?s).*Zulu Zone 4.*" +- tapOn: "(?s)^Libraries.*" +- extendedWaitUntil: + visible: "Guest Movies" + timeout: 15000 +- assertNotVisible: "Maestro Movies" +- tapOn: "(?s)^Home.*" +- extendedWaitUntil: + visible: "(?s).*Guest Galaxy$" + timeout: 15000 +- tapOn: "E" +- tapOn: + text: "Profiles" + above: + text: "Settings" +- extendedWaitUntil: + visible: "Switch Profile" + timeout: 10000 +- tapOn: "(?s)^M\nMaestro\n.*" +- extendedWaitUntil: + visible: "(?s).*Zulu Zone 4.*" + timeout: 30000 +- assertNotVisible: "(?s).*Guest Galaxy$" +- tapOn: "(?s)^Libraries.*" +- extendedWaitUntil: + visible: "Maestro Movies" + timeout: 15000 +- assertNotVisible: "Guest Movies" +- tapOn: "(?s)^Home.*" +- extendedWaitUntil: + visible: "(?s).*Zulu Zone 4.*" + timeout: 15000 +- tapOn: "M" +- tapOn: + text: "Profiles" + above: + text: "Settings" +- extendedWaitUntil: + visible: "Switch Profile" + timeout: 10000 +- tapOn: "(?s)^E\nE2E Guest\n.*" +- extendedWaitUntil: + visible: "(?s).*Guest Galaxy$" + timeout: 30000 +- assertNotVisible: "(?s).*Zulu Zone 4.*" diff --git a/.maestro/regression_flows/02_profile_teardown.yaml b/.maestro/regression_flows/02_profile_teardown.yaml new file mode 100644 index 00000000..3f35d024 --- /dev/null +++ b/.maestro/regression_flows/02_profile_teardown.yaml @@ -0,0 +1,48 @@ +appId: com.edde746.plezy +name: Removing a Jellyfin profile connection leaves no orphaned session + +tags: + - e2e + - regression + - profiles + - teardown +--- +- runFlow: ../subflows/onboard_jellyfin.yaml +- runFlow: ../subflows/create_guest_jellyfin_profile.yaml +- tapOn: + text: "Manage" + index: 1 +- tapOn: "Manage" +- extendedWaitUntil: + visible: "Profile name" + timeout: 10000 +- assertVisible: "(?s)^E2E Guest.*" +- assertVisible: "(?s)^Maestro Jellyfin.*Default.*Manage$" +- tapOn: "Manage" +- extendedWaitUntil: + visible: "Remove" + timeout: 10000 +- tapOn: "Remove" +- extendedWaitUntil: + visible: "^Remove connection\\?$" + timeout: 10000 +- assertVisible: "(?s).*E2E Guest's access to Maestro Jellyfin.*" +- tapOn: "Remove" +- extendedWaitUntil: + notVisible: "(?s)^Maestro Jellyfin.*Default.*Manage$" + timeout: 15000 +- assertVisible: "Delete profile" +- tapOn: "Delete profile" +- extendedWaitUntil: + visible: "^Delete profile\\?$" + timeout: 10000 +- tapOn: "^Delete$" +- extendedWaitUntil: + visible: "Switch Profile" + timeout: 30000 +- assertNotVisible: "(?s).*E2E Guest.*" +- assertVisible: "(?s)^M\nMaestro\nActive.*" +- back +- extendedWaitUntil: + visible: "(?s).*Zulu Zone 4.*" + timeout: 30000 diff --git a/.maestro/regression_flows/03_tv_library_focus.yaml b/.maestro/regression_flows/03_tv_library_focus.yaml new file mode 100644 index 00000000..f18793e1 --- /dev/null +++ b/.maestro/regression_flows/03_tv_library_focus.yaml @@ -0,0 +1,68 @@ +appId: com.edde746.plezy +name: TV alphabet rail retains focus until returning to the media grid + +tags: + - e2e + - regression + - tv + - focus +--- +- runFlow: ../subflows/onboard_jellyfin_tv.yaml +- pressKey: "Remote Dpad Left" +- pressKey: "Remote Dpad Down" +- pressKey: "Remote Dpad Down" +- pressKey: "Remote Dpad Center" +- extendedWaitUntil: + visible: "Recommended" + timeout: 15000 +- pressKey: "Remote Dpad Right" +- pressKey: "Remote Dpad Up" +- pressKey: "Remote Dpad Right" +- pressKey: "Remote Dpad Center" +- pressKey: "Remote Dpad Up" +- pressKey: "Remote Dpad Right" +- pressKey: "Remote Dpad Right" +- pressKey: "Remote Dpad Center" +- extendedWaitUntil: + visible: "Title" + timeout: 10000 +- pressKey: "Remote Dpad Center" +- pressKey: "Remote Dpad Left" +- extendedWaitUntil: + notVisible: "Sort by" + timeout: 10000 +- pressKey: "Remote Dpad Down" +- extendedWaitUntil: + visible: "(?s).*Alpha Archive.*" + timeout: 15000 +- repeat: + times: 8 + commands: + - pressKey: "Remote Dpad Right" +- repeat: + times: 13 + commands: + - pressKey: "Remote Dpad Down" +- extendedWaitUntil: + visible: "(?s).*Mike Matrix.*" + timeout: 15000 +- assertNotVisible: "(?s).*Alpha Archive.*" +- repeat: + times: 13 + commands: + - pressKey: "Remote Dpad Down" +- extendedWaitUntil: + visible: "(?s).*Zulu Zone.*" + timeout: 15000 +- assertNotVisible: "(?s).*Mike Matrix.*" +- pressKey: "Remote Dpad Left" +- pressKey: "Remote Dpad Center" +- extendedWaitUntil: + visible: "Play" + timeout: 15000 +- assertVisible: "(?s).*Zulu Zone.*" +- pressKey: "back" +- extendedWaitUntil: + visible: "Browse" + timeout: 15000 +- assertVisible: "(?s).*Zulu Zone.*" diff --git a/.maestro/regression_flows/04_tv_player_keys.yaml b/.maestro/regression_flows/04_tv_player_keys.yaml new file mode 100644 index 00000000..ef626d1c --- /dev/null +++ b/.maestro/regression_flows/04_tv_player_keys.yaml @@ -0,0 +1,39 @@ +appId: com.edde746.plezy +name: TV hardware media and back keys follow player chrome state + +tags: + - e2e + - regression + - tv + - playback +--- +- runFlow: ../subflows/onboard_jellyfin_tv.yaml +- pressKey: "Remote Dpad Center" +- extendedWaitUntil: + visible: "Play" + timeout: 15000 +- pressKey: "Remote Dpad Center" +- extendedWaitUntil: + visible: "Pause" + timeout: 30000 +- extendedWaitUntil: + notVisible: "Pause" + timeout: 15000 +- pressKey: "Remote Media Play Pause" +- extendedWaitUntil: + visible: "Play" + timeout: 10000 +- pressKey: "Remote Media Play Pause" +- extendedWaitUntil: + visible: "Pause" + timeout: 10000 +- pressKey: "back" +- extendedWaitUntil: + notVisible: "Pause" + timeout: 10000 +- assertNotVisible: "Overview" +- pressKey: "back" +- extendedWaitUntil: + visible: "(?s)^(Play|Resume).*$" + timeout: 15000 +- assertVisible: "(?s).*Zulu Zone 4.*" diff --git a/.maestro/regression_flows/05_tv_next_episode_back.yaml b/.maestro/regression_flows/05_tv_next_episode_back.yaml new file mode 100644 index 00000000..d232d423 --- /dev/null +++ b/.maestro/regression_flows/05_tv_next_episode_back.yaml @@ -0,0 +1,43 @@ +appId: com.edde746.plezy +name: TV Back dismisses Next Episode without leaving playback +tags: + - e2e + - regression + - tv + - playback +--- +- runFlow: ../subflows/onboard_jellyfin_tv.yaml +- tapOn: + point: "6%,22%" +- tapOn: "Search" +- extendedWaitUntil: + visible: "Search movies, shows, music..." + timeout: 10000 +- tapOn: "Search movies, shows, music..." +- inputText: "Maestro Show" +- tapOn: + point: "87%,90%" +- extendedWaitUntil: + visible: "(?s).*Maestro Show$" + timeout: 15000 +- tapOn: + point: "50%,24%" +- waitForAnimationToEnd: + timeout: 5000 +- tapOn: + point: "15%,76%" +- extendedWaitUntil: + visible: "Pause" + timeout: 30000 +- pressKey: "Remote Media Fast Forward" +- pressKey: "Remote Media Fast Forward" +- extendedWaitUntil: + visible: "Next Episode" + timeout: 20000 +- pressKey: "back" +- assertNotVisible: "Next Episode" +- assertNotVisible: "Overview" +- pressKey: "Remote Dpad Up" +- extendedWaitUntil: + visible: "(?s).*Maestro Episode 1.*" + timeout: 10000 diff --git a/.maestro/regression_flows/06_playback_recovery.yaml b/.maestro/regression_flows/06_playback_recovery.yaml new file mode 100644 index 00000000..fce18d0e --- /dev/null +++ b/.maestro/regression_flows/06_playback_recovery.yaml @@ -0,0 +1,32 @@ +appId: com.edde746.plezy +name: Playback recovers after a transient stream failure +tags: + - e2e + - regression + - playback + - recovery +--- +- runFlow: ../subflows/onboard_jellyfin.yaml +- tapOn: "(?s)^Libraries.*" +- extendedWaitUntil: + visible: "(?s).*Zulu Zone 4$" + timeout: 15000 +- tapOn: "(?s).*Zulu Zone 4$" +- extendedWaitUntil: + visible: "Play" + timeout: 15000 +- tapOn: + text: "Play" + waitToSettleTimeoutMs: 1000 +- extendedWaitUntil: + visible: "Pause" + timeout: 45000 +- assertNotVisible: "Overview" +- back +- extendedWaitUntil: + notVisible: "Pause" + timeout: 10000 +- back +- extendedWaitUntil: + visible: "Overview" + timeout: 15000 diff --git a/.maestro/subflows/create_guest_jellyfin_profile.yaml b/.maestro/subflows/create_guest_jellyfin_profile.yaml new file mode 100644 index 00000000..0144eb2e --- /dev/null +++ b/.maestro/subflows/create_guest_jellyfin_profile.yaml @@ -0,0 +1,43 @@ +appId: com.edde746.plezy +--- +- tapOn: "M" +- tapOn: + text: "Profiles" + above: + text: "Settings" +- extendedWaitUntil: + visible: "Switch Profile" + timeout: 10000 +- tapOn: "Add Plezy profile" +- extendedWaitUntil: + visible: "New profile" + timeout: 10000 +- tapOn: "e.g. Guests, Kids, Family Room" +- inputText: "E2E Guest" +- hideKeyboard +- tapOn: "Continue" +- extendedWaitUntil: + visible: "Add to E2E Guest" + timeout: 10000 +- tapOn: "(?s)^Connect to Jellyfin.*" +- extendedWaitUntil: + visible: "Add Jellyfin server" + timeout: 10000 +- tapOn: "(?s)Server URLs.*" +- inputText: ${JELLYFIN_URL} +- hideKeyboard +- tapOn: "Find server" +- extendedWaitUntil: + visible: "Maestro Jellyfin" + timeout: 15000 +- tapOn: "Username" +- inputText: "guest" +- tapOn: "Password" +- inputText: "guest" +- hideKeyboard +- tapOn: "Sign in" +- extendedWaitUntil: + visible: "Switch Profile" + timeout: 30000 +- assertVisible: "(?s)^M\nMaestro\n.*" +- assertVisible: "(?s)^E\nE2E Guest\n.*" diff --git a/.maestro/subflows/onboard_jellyfin.yaml b/.maestro/subflows/onboard_jellyfin.yaml new file mode 100644 index 00000000..80b0adc8 --- /dev/null +++ b/.maestro/subflows/onboard_jellyfin.yaml @@ -0,0 +1,38 @@ +appId: com.edde746.plezy +--- +- retry: + maxRetries: 1 + commands: + - launchApp: + clearState: true + permissions: + all: allow + - extendedWaitUntil: + visible: "(?s)^(?:Connect to Jellyfin|Wait).*" + timeout: 30000 + - tapOn: + text: "Wait" + optional: true + - extendedWaitUntil: + visible: "Connect to Jellyfin" + timeout: 30000 +- tapOn: "Connect to Jellyfin" +- extendedWaitUntil: + visible: "Add Jellyfin server" + timeout: 10000 +- tapOn: "(?s)Server URLs.*" +- inputText: ${JELLYFIN_URL} +- hideKeyboard +- tapOn: "Find server" +- extendedWaitUntil: + visible: "Maestro Jellyfin" + timeout: 15000 +- tapOn: "Username" +- inputText: "maestro" +- tapOn: "Password" +- inputText: "maestro" +- hideKeyboard +- tapOn: "Sign in" +- extendedWaitUntil: + visible: "Discover" + timeout: 30000 diff --git a/.maestro/subflows/onboard_jellyfin_tv.yaml b/.maestro/subflows/onboard_jellyfin_tv.yaml new file mode 100644 index 00000000..c09f4994 --- /dev/null +++ b/.maestro/subflows/onboard_jellyfin_tv.yaml @@ -0,0 +1,54 @@ +appId: com.edde746.plezy +--- +- retry: + maxRetries: 1 + commands: + - launchApp: + appId: com.edde746.plezy + clearState: true + permissions: + all: allow + - extendedWaitUntil: + visible: "(?s)^(?:Connect to Jellyfin|Wait).*" + timeout: 30000 + - tapOn: + text: "Wait" + optional: true + - extendedWaitUntil: + visible: "Connect to Jellyfin" + timeout: 30000 +- tapOn: "Connect to Jellyfin" +- extendedWaitUntil: + visible: "Add Jellyfin server" + timeout: 10000 +- tapOn: "(?s)Server URLs.*" +- inputText: ${JELLYFIN_URL} +- tapOn: "Find server" +- extendedWaitUntil: + visible: "Username" + timeout: 15000 +- tapOn: "Username" +- inputText: "maestro" +- tapOn: "Password" +- inputText: "maestro" +- tapOn: "Sign in" +- extendedWaitUntil: + visible: "Discover" + timeout: 30000 +- tapOn: "M" +- tapOn: + text: "Settings" + below: + text: "Profiles" +- extendedWaitUntil: + visible: "(?s)^Appearance.*" + timeout: 10000 +- tapOn: "(?s)^Appearance.*" +- swipe: + start: 50%, 85% + end: 50%, 25% + duration: 500 +- tapOn: "(?s)^Force TV mode.*" +- extendedWaitUntil: + visible: "(?s).*Zulu Zone 4.*" + timeout: 30000 diff --git a/.maestro/subflows/open_codec_sample.yaml b/.maestro/subflows/open_codec_sample.yaml new file mode 100644 index 00000000..b77c0c51 --- /dev/null +++ b/.maestro/subflows/open_codec_sample.yaml @@ -0,0 +1,26 @@ +appId: com.edde746.plezy +--- +- runFlow: onboard_jellyfin.yaml +- tapOn: "(?s)^Search.*" +- extendedWaitUntil: + visible: "Search movies, shows, music..." + timeout: 10000 +- tapOn: "Search movies, shows, music..." +- inputText: "${SAMPLE_TITLE}" +- hideKeyboard +- extendedWaitUntil: + visible: "(?s).*${SAMPLE_TITLE}$" + timeout: 15000 +- tapOn: + point: "50%,24%" +- extendedWaitUntil: + visible: "Overview" + timeout: 15000 +- assertVisible: "${SAMPLE_OVERVIEW}" +- tapOn: + text: "Play" + waitToSettleTimeoutMs: 1000 +- extendedWaitUntil: + notVisible: "Overview" + timeout: 15000 +- pressKey: "Remote Media Play Pause" diff --git a/.maestro/subflows/show_codec_track_sheet.yaml b/.maestro/subflows/show_codec_track_sheet.yaml new file mode 100644 index 00000000..75f35d2e --- /dev/null +++ b/.maestro/subflows/show_codec_track_sheet.yaml @@ -0,0 +1,21 @@ +appId: com.edde746.plezy +--- +- runFlow: + when: + notVisible: "${SHEET_MARKER}" + commands: + - repeat: + times: 8 + while: + notVisible: "${SHEET_MARKER}" + commands: + - tapOn: + point: "50%,50%" + - runFlow: + when: + visible: "Pause" + commands: + - pressKey: "Remote Media Play Pause" + - tapOn: + point: "75%,11%" +- assertVisible: "${SHEET_MARKER}" diff --git a/.maestro/subflows/switch_codec_tracks.yaml b/.maestro/subflows/switch_codec_tracks.yaml new file mode 100644 index 00000000..f76db884 --- /dev/null +++ b/.maestro/subflows/switch_codec_tracks.yaml @@ -0,0 +1,27 @@ +appId: com.edde746.plezy +--- +- runFlow: + file: show_codec_track_sheet.yaml + env: + SHEET_MARKER: "${AUDIO_TRACK}" +- assertVisible: "${AUDIO_TRACK}" +- tapOn: + point: "30%,70%" +- runFlow: + file: show_codec_track_sheet.yaml + env: + SHEET_MARKER: "${AUDIO_TRACK}" +- assertVisible: "${SUBTITLE_TRACK}" +- tapOn: + point: "70%,66%" +- runFlow: + file: show_codec_track_sheet.yaml + env: + SHEET_MARKER: "${AUDIO_TRACK}" +- assertVisible: + text: "${AUDIO_TRACK}" + selected: true +- assertVisible: + text: "${SUBTITLE_TRACK}" + selected: true +- back diff --git a/.maestro/subflows/switch_codec_tracks_deep.yaml b/.maestro/subflows/switch_codec_tracks_deep.yaml new file mode 100644 index 00000000..e1e9d408 --- /dev/null +++ b/.maestro/subflows/switch_codec_tracks_deep.yaml @@ -0,0 +1,40 @@ +appId: com.edde746.plezy +--- +- runFlow: + file: show_codec_track_sheet.yaml + env: + SHEET_MARKER: "${SHEET_MARKER}" +- assertVisible: "${AUDIO_TRACK}" +- tapOn: + point: "30%,84%" +- runFlow: + file: show_codec_track_sheet.yaml + env: + SHEET_MARKER: "${AUDIO_TRACK}" +- repeat: + times: 12 + while: + notVisible: "${SUBTITLE_TRACK}" + commands: + - swipe: + start: 70%, 82% + end: 70%, 68% + duration: 300 +- assertVisible: "${SUBTITLE_TRACK}" +- swipe: + start: 70%, 78% + end: 70%, 68% + duration: 300 +- tapOn: + point: "70%,86%" +- runFlow: + file: show_codec_track_sheet.yaml + env: + SHEET_MARKER: "${AUDIO_TRACK}" +- assertVisible: + text: "${AUDIO_TRACK}" + selected: true +- assertVisible: + text: "${SUBTITLE_TRACK}" + selected: true +- back diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 19dbabd6..152ae271 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,6 +38,34 @@ The project includes automated CI checks that run on all pull requests: All these checks must pass before your changes can be merged. +### Maestro end-to-end tests + +Android E2E tests use [Maestro](https://maestro.mobile.dev/) against a disposable, pre-seeded Jellyfin container. + +Prerequisites: Java 17, Flutter and Android SDK/platform tools, a running Android emulator, Docker, and the +[Maestro CLI](https://docs.maestro.dev/getting-started/installing-maestro). + +Run the suites from the repository root (`py -3` can replace `python3` on Windows): + +```bash +python3 scripts/run_maestro.py basic # Basic user flows +python3 scripts/run_maestro.py catalog # Catalog and music flows +python3 scripts/run_maestro.py media # Codec playback and track selection +``` + +Run one flow with `--flow`: + +```bash +python3 scripts/run_maestro.py basic --flow .maestro/flows/04_search.yaml +``` + +Use `--skip-build` to reuse the debug APK and `--skip-jellyfin-build` to reuse the Jellyfin image. Set +`--device ` when multiple devices are connected; physical devices also require `--adb-reverse`. + +Top-level flows live in `.maestro/flows/`, shared setup in `.maestro/subflows/`, and focused regressions in +`.maestro/regression_flows/`. CI runs the same suites from `.github/workflows/e2e.yml` and uploads diagnostics on +failure. + ## Internationalization (i18n) This project uses `slang` for internationalization with JSON files. diff --git a/lib/main.dart b/lib/main.dart index cdeccb00..790c416b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -112,8 +112,8 @@ void _registerTvosPlatformPlugins() { Future main() async { final binding = WidgetsFlutterBinding.ensureInitialized(); - // Build the semantics tree in debug so Maestro/UI automation can locate - // widgets by text. Zero cost in release builds. + // Keep the accessibility tree available to Maestro and other UI automation + // without adding release-build overhead. if (kDebugMode) binding.ensureSemantics(); _installZeroOffsetPointerGuard(); // Workaround for iPadOS 26.1+ modal dismissal bug diff --git a/lib/screens/media_detail/action_buttons.dart b/lib/screens/media_detail/action_buttons.dart index 71af4df3..66d1efac 100644 --- a/lib/screens/media_detail/action_buttons.dart +++ b/lib/screens/media_detail/action_buttons.dart @@ -6,9 +6,12 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { final tvScale = TvLayoutConstants.scaleOf(context); final actionSize = isTv ? _tvDetailActionSize * tvScale : 48.0; final playButtonLabel = _getPlayButtonLabel(metadata); + final playIcon = _getPlayButtonIcon(metadata); + final playActionLabel = playIcon == Symbols.resume_rounded ? t.common.resume : t.common.play; + final playSemanticsLabel = playButtonLabel.isEmpty ? playActionLabel : '$playActionLabel $playButtonLabel'; final playIconSize = isTv ? 22 * tvScale : 20.0; final playTextStyle = TextStyle(fontSize: isTv ? 17 * tvScale : 16, fontWeight: .w700); - final playButtonIcon = AppIcon(_getPlayButtonIcon(metadata), fill: 1, size: playIconSize); + final playButtonIcon = AppIcon(playIcon, fill: 1, size: playIconSize); Future onPlayPressed() async { // For TV shows, play the OnDeck episode if available @@ -102,24 +105,30 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { final gap = isTv ? 8.0 * tvScale : 12.0; Widget playButton(FocusableActionBuildState state) { - return SizedBox( - height: actionSize, - child: FilledButton( - onPressed: onPlayPressed, - style: actionButtonStyle( - showFocus: state.showFocus, - padding: .symmetric(horizontal: isTv ? 17 * tvScale : 16, vertical: isTv ? 9 * tvScale : 0), + return Semantics( + label: playSemanticsLabel, + button: true, + onTap: onPlayPressed, + excludeSemantics: true, + child: SizedBox( + height: actionSize, + child: FilledButton( + onPressed: onPlayPressed, + style: actionButtonStyle( + showFocus: state.showFocus, + padding: .symmetric(horizontal: isTv ? 17 * tvScale : 16, vertical: isTv ? 9 * tvScale : 0), + ), + child: playButtonLabel.isNotEmpty + ? Row( + mainAxisSize: .min, + children: [ + playButtonIcon, + SizedBox(width: isTv ? 7 * tvScale : 8), + Text(playButtonLabel, style: playTextStyle), + ], + ) + : playButtonIcon, ), - child: playButtonLabel.isNotEmpty - ? Row( - mainAxisSize: .min, - children: [ - playButtonIcon, - SizedBox(width: isTv ? 7 * tvScale : 8), - Text(playButtonLabel, style: playTextStyle), - ], - ) - : playButtonIcon, ), ); } diff --git a/lib/widgets/video_controls/helpers/track_selection_helper.dart b/lib/widgets/video_controls/helpers/track_selection_helper.dart index 1d1e70f9..e541cedf 100644 --- a/lib/widgets/video_controls/helpers/track_selection_helper.dart +++ b/lib/widgets/video_controls/helpers/track_selection_helper.dart @@ -122,6 +122,7 @@ class TrackSelectionHelper { Widget tile = FocusableListTile( key: key, focusNode: focusNode, + selected: isSelected, title: Text( label, style: TextStyle(color: isSelected ? primaryColor : null), diff --git a/scripts/ci_checks.sh b/scripts/ci_checks.sh index 5f1741b7..183883be 100755 --- a/scripts/ci_checks.sh +++ b/scripts/ci_checks.sh @@ -86,6 +86,7 @@ if python3 scripts/check_build_workflow.py && python3 scripts/check_update_packages_workflow.py && python3 scripts/test_pubspec_version.py && python3 scripts/test_clean_translations.py && + python3 scripts/test_run_maestro.py && python3 scripts/test_check_icon_consistency.py; then ok "workflow and script guards passed" else diff --git a/scripts/maestro_fixtures.py b/scripts/maestro_fixtures.py new file mode 100644 index 00000000..2f52f3f3 --- /dev/null +++ b/scripts/maestro_fixtures.py @@ -0,0 +1,135 @@ +"""Deterministic media assets and hosted codec fixture contracts.""" + +from __future__ import annotations + +import base64 +from dataclasses import dataclass +import io +import math +import struct +import wave + + +@dataclass(frozen=True) +class MediaFixtureSpec: + id: str + title: str + filename: str + overview: str + video_codec: str + width: int + height: int + size_bytes: int + sha256: str + + +MEDIA_FIXTURE_SPECS = ( + MediaFixtureSpec( + id="codec-dv-uhd", + title="Codec DV UHD TrueHD PGS", + filename="dv8_hdr10plus_truehd_pgs.mkv", + overview="Dolby Vision profile 8 with an HDR fallback, TrueHD Atmos, AC-3, and PGS subtitles.", + video_codec="hevc", + width=3840, + height=1604, + size_bytes=154537349, + sha256="1feac8feb3bf31323a8da16a9db22549e1acfa7d9469b4b85deaa2bd145ec84c", + ), + MediaFixtureSpec( + id="codec-dv-web", + title="Codec DV Web EAC3 SRT", + filename="dv8_hdr_web_eac3_atmos_srt.mkv", + overview="Dolby Vision profile 8 with an HDR fallback, E-AC-3 Atmos, and multilingual SRT subtitles.", + video_codec="hevc", + width=3840, + height=1606, + size_bytes=120581060, + sha256="9bc5e1f90c20de9317800f51424f11366bb16861f10853e93625bb649ea34343", + ), + MediaFixtureSpec( + id="codec-av1", + title="Codec AV1 Opus ASS", + filename="av1_opus_ass_srt.mkv", + overview="AV1 video with multilingual Opus audio, styled ASS subtitles, SRT subtitles, and embedded fonts.", + video_codec="av1", + width=1916, + height=1072, + size_bytes=6744111, + sha256="15a825c0d2d70c7f593cde1d86c1044c440b6745735a4e53c212878fd365fccd", + ), + MediaFixtureSpec( + id="codec-h264-high10", + title="Codec H264 High10 DTS-HD ASS", + filename="h264_high10_dts_hd_ass.mkv", + overview="H.264 High 10 video with dual DTS-HD MA audio, styled ASS subtitles, and embedded fonts.", + video_codec="h264", + width=1920, + height=1080, + size_bytes=57191760, + sha256="0fb22b97f3542e228d55b80aa2893c3d92934192c908e8f96e696dbaaafcfe86", + ), + MediaFixtureSpec( + id="codec-h264-eac3", + title="Codec H264 EAC3 Multisub", + filename="h264_eac3_multisub.mkv", + overview="H.264 video with three E-AC-3 audio tracks and a broad multilingual SRT subtitle set.", + video_codec="h264", + width=1920, + height=1080, + size_bytes=26793597, + sha256="759cc11148b4358611f6bbc1c9a6db014ecc6dce9b907993c8e51bd885f94d18", + ), + MediaFixtureSpec( + id="codec-hevc-flac", + title="Codec HEVC10 FLAC ASS", + filename="hevc10_flac_ass.mkv", + overview="HEVC Main 10 video with dual FLAC 5.1 audio, styled ASS subtitles, and embedded fonts.", + video_codec="hevc", + width=1920, + height=1080, + size_bytes=45578288, + sha256="af8950e3c5a487e39b2f23df371583fa7469003da72df723be3d0f0071cf3414", + ), +) + +# Thirty-second 160x90 H.264 baseline MP4 generated once with ffmpeg. Embedding the +# payload keeps CI independent of ffmpeg and gives the real player a valid range- +# requestable stream long enough for chrome auto-hide and remote-key regressions. +_VIDEO = base64.b64decode( + "AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1wNDEAAAjJbW9vdgAAAGxtdmhkAAAAAAAAAAAAAAAAAAAD6AAAdTAAAQAAAQAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAB/N0cmFrAAAAXHRraGQAAAADAAAAAAAAAAAAAAABAAAAAAAAdTAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAKAAAABaAAAAAAAkZWR0cwAAABxlbHN0AAAAAAAAAAEAAHUwAAAAAAABAAAAAAdrbWRpYQAAACBtZGhkAAAAAAAAAAAAAAAAAAAwAAAFoABVxAAAAAAALWhkbHIAAAAAAAAAAHZpZGUAAAAAAAAAAAAAAABWaWRlb0hhbmRsZXIAAAAHFm1pbmYAAAAUdm1oZAAAAAEAAAAAAAAAAAAAACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAABtZzdGJsAAAAunN0c2QAAAAAAAAAAQAAAKphdmMxAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAKAAWgBIAAAASAAAAAAAAAABFUxhdmM2Mi4yOC4xMDIgbGlieDI2NAAAAAAAAAAAAAAAGP//AAAAMGF2Y0MBQsAe/+EAGGdCwB7ZAo35MBEAAAMAAQAAAwAYDxYuSAEABWjLg8sgAAAAEHBhc3AAAAABAAAAAQAAABRidHJ0AAAAAAAABIMAAAAAAAAAGHN0dHMAAAAAAAAAAQAAAWgAAAQAAAAAGHN0c3MAAAAAAAAAAgAAAAEAAAD7AAAAHHN0c2MAAAAAAAAAAQAAAAEAAAFoAAAAAQAABbRzdHN6AAAAAAAAAAAAAAFoAAACsgAAAAoAAAALAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAD0AAAAKAAAACwAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAAFHN0Y28AAAAAAAAAAQAACPkAAABidWR0YQAAAFptZXRhAAAAAAAAACFoZGxyAAAAAAAAAABtZGlyYXBwbAAAAAAAAAAAAAAAAC1pbHN0AAAAJal0b28AAAAdZGF0YQAAAAEAAAAATGF2ZjYyLjEyLjEwMgAAAAhmcmVlAAAQ9W1kYXQAAAJxBgX//23cRem95tlIt5Ys2CDZI+7veDI2NCAtIGNvcmUgMTY1IHIzMjIyIGIzNTYwNWEgLSBILjI2NC9NUEVHLTQgQVZDIGNvZGVjIC0gQ29weWxlZnQgMjAwMy0yMDI1IC0gaHR0cDovL3d3dy52aWRlb2xhbi5vcmcveDI2NC5odG1sIC0gb3B0aW9uczogY2FiYWM9MCByZWY9MyBkZWJsb2NrPTE6MDowIGFuYWx5c2U9MHgxOjB4MTExIG1lPWhleCBzdWJtZT03IHBzeT0xIHBzeV9yZD0xLjAwOjAuMDAgbWl4ZWRfcmVmPTEgbWVfcmFuZ2U9MTYgY2hyb21hX21lPTEgdHJlbGxpcz0xIDh4OGRjdD0wIGNxbT0wIGRlYWR6b25lPTIxLDExIGZhc3RfcHNraXA9MSBjaHJvbWFfcXBfb2Zmc2V0PS0yIHRocmVhZHM9MyBsb29rYWhlYWRfdGhyZWFkcz0xIHNsaWNlZF90aHJlYWRzPTAgbnI9MCBkZWNpbWF0ZT0xIGludGVybGFjZWQ9MCBibHVyYXlfY29tcGF0PTAgY29uc3RyYWluZWRfaW50cmE9MCBiZnJhbWVzPTAgd2VpZ2h0cD0wIGtleWludD0yNTAga2V5aW50X21pbj0xMiBzY2VuZWN1dD00MCBpbnRyYV9yZWZyZXNoPTAgcmNfbG9va2FoZWFkPTQwIHJjPWNyZiBtYnRyZWU9MSBjcmY9MjMuMCBxY29tcD0wLjYwIHFwbWluPTAgcXBtYXg9NjkgcXBzdGVwPTQgaXBfcmF0aW89MS40MCBhcT0xOjEuMDAAgAAAADlliIQO8mKAAL78nJycnJycnJyddddddddddddddddddddddddddddddddddddddddddddddddddeAAAAAGQZo4HeD2AAAAB0GaVAd4PYAAAAAGQZpgO8HsAAAABkGagDvB7AAAAAZBmqA7wewAAAAGQZrAO8HsAAAABkGa4DvB7AAAAAZBmwA7wewAAAAGQZsgO8HsAAAABkGbQDvB7AAAAAZBm2A7wewAAAAGQZuAO8HsAAAABkGboDvB7AAAAAZBm8A7wewAAAAGQZvgO8HsAAAABkGaADvB7AAAAAZBmiA7wewAAAAGQZpAO8HsAAAABkGaYDvB7AAAAAZBmoA7wewAAAAGQZqgO8HsAAAABkGawDvB7AAAAAZBmuA7wewAAAAGQZsAO8HsAAAABkGbIDvB7AAAAAZBm0A7wewAAAAGQZtgO8HsAAAABkGbgDvB7AAAAAZBm6A7wewAAAAGQZvAO8HsAAAABkGb4DvB7AAAAAZBmgA7wewAAAAGQZogO8HsAAAABkGaQDvB7AAAAAZBmmA7wewAAAAGQZqAO8HsAAAABkGaoDvB7AAAAAZBmsA7wewAAAAGQZrgO8HsAAAABkGbADvB7AAAAAZBmyA7wewAAAAGQZtAO8HsAAAABkGbYDvB7AAAAAZBm4A7wewAAAAGQZugO8HsAAAABkGbwDvB7AAAAAZBm+A7wewAAAAGQZoAO8HsAAAABkGaIDvB7AAAAAZBmkA7wewAAAAGQZpgO8HsAAAABkGagDvB7AAAAAZBmqA7wewAAAAGQZrAO8HsAAAABkGa4DvB7AAAAAZBmwA7wewAAAAGQZsgO8HsAAAABkGbQDvB7AAAAAZBm2A7wewAAAAGQZuAO8HsAAAABkGboDvB7AAAAAZBm8A7wewAAAAGQZvgO8HsAAAABkGaADvB7AAAAAZBmiA7wewAAAAGQZpAO8HsAAAABkGaYDvB7AAAAAZBmoA7wewAAAAGQZqgO8HsAAAABkGawDvB7AAAAAZBmuA7wewAAAAGQZsAO8HsAAAABkGbIDvB7AAAAAZBm0A7wewAAAAGQZtgO8HsAAAABkGbgDvB7AAAAAZBm6A7wewAAAAGQZvAO8HsAAAABkGb4DvB7AAAAAZBmgA7wewAAAAGQZogO8HsAAAABkGaQDvB7AAAAAZBmmA7wewAAAAGQZqAO8HsAAAABkGaoDvB7AAAAAZBmsA7wewAAAAGQZrgO8HsAAAABkGbADvB7AAAAAZBmyA7wewAAAAGQZtAO8HsAAAABkGbYDvB7AAAAAZBm4A7wewAAAAGQZugO8HsAAAABkGbwDvB7AAAAAZBm+A7wewAAAAGQZoAO8HsAAAABkGaIDvB7AAAAAZBmkA7wewAAAAGQZpgO8HsAAAABkGagDvB7AAAAAZBmqA7wewAAAAGQZrAO8HsAAAABkGa4DvB7AAAAAZBmwA7wewAAAAGQZsgO8HsAAAABkGbQDvB7AAAAAZBm2A7wewAAAAGQZuAO8HsAAAABkGboDvB7AAAAAZBm8A7wewAAAAGQZvgO8HsAAAABkGaADvB7AAAAAZBmiA7wewAAAAGQZpAO8HsAAAABkGaYDvB7AAAAAZBmoA7wewAAAAGQZqgO8HsAAAABkGawDvB7AAAAAZBmuA7wewAAAAGQZsAO8HsAAAABkGbIDvB7AAAAAZBm0A7wewAAAAGQZtgO8HsAAAABkGbgDvB7AAAAAZBm6A7wewAAAAGQZvAO8HsAAAABkGb4DvB7AAAAAZBmgA7wewAAAAGQZogO8HsAAAABkGaQDvB7AAAAAZBmmA7wewAAAAGQZqAO8HsAAAABkGaoDvB7AAAAAZBmsA7wewAAAAGQZrgO8HsAAAABkGbADvB7AAAAAZBmyA7wewAAAAGQZtAO8HsAAAABkGbYDvB7AAAAAZBm4A7wewAAAAGQZugO8HsAAAABkGbwDvB7AAAAAZBm+A7wewAAAAGQZoAO8HsAAAABkGaIDvB7AAAAAZBmkA7wewAAAAGQZpgO8HsAAAABkGagDvB7AAAAAZBmqA7wewAAAAGQZrAO8HsAAAABkGa4DvB7AAAAAZBmwA7wewAAAAGQZsgO8HsAAAABkGbQDvB7AAAAAZBm2A7wewAAAAGQZuAO8HsAAAABkGboDvB7AAAAAZBm8A7wewAAAAGQZvgO8HsAAAABkGaADvB7AAAAAZBmiA7wewAAAAGQZpAO8HsAAAABkGaYDvB7AAAAAZBmoA7wewAAAAGQZqgO8HsAAAABkGawDvB7AAAAAZBmuA7wewAAAAGQZsAO8HsAAAABkGbIDvB7AAAAAZBm0A7wewAAAAGQZtgO8HsAAAABkGbgDvB7AAAAAZBm6A7wewAAAAGQZvAO8HsAAAABkGb4DvB7AAAAAZBmgA7wewAAAAGQZogO8HsAAAABkGaQDvB7AAAAAZBmmA7wewAAAAGQZqAO8HsAAAABkGaoDvB7AAAAAZBmsA7wewAAAAGQZrgO8HsAAAABkGbADvB7AAAAAZBmyA7wewAAAAGQZtAO8HsAAAABkGbYDvB7AAAAAZBm4A7wewAAAAGQZugO8HsAAAABkGbwDvB7AAAAAZBm+A7wewAAAAGQZoAO8HsAAAABkGaIDvB7AAAAAZBmkA7wewAAAAGQZpgO8HsAAAABkGagDvB7AAAAAZBmqA7wewAAAAGQZrAO8HsAAAABkGa4DvB7AAAAAZBmwA7wewAAAAGQZsgO8HsAAAABkGbQDvB7AAAAAZBm2A7wewAAAAGQZuAO8HsAAAABkGboDvB7AAAAAZBm8A7wewAAAAGQZvgO8HsAAAABkGaADvB7AAAAAZBmiA7wewAAAAGQZpAO8HsAAAABkGaYDvB7AAAAAZBmoA7wewAAAAGQZqgO8HsAAAABkGawDvB7AAAAAZBmuA7wewAAAAGQZsAO8HsAAAABkGbIDvB7AAAAAZBm0A7wewAAAAGQZtgO8HsAAAABkGbgDvB7AAAAAZBm6A7wewAAAAGQZvAO8HsAAAABkGb4DvB7AAAAAZBmgA7wewAAAAGQZogO8HsAAAABkGaQDvB7AAAAAZBmmA7wewAAAAGQZqAO8HsAAAABkGaoDvB7AAAAAZBmsA7wewAAAAGQZrgO8HsAAAABkGbADvB7AAAAAZBmyA7wewAAAAGQZtAO8HsAAAABkGbYDvB7AAAAAZBm4A7wewAAAAGQZugO8HsAAAABkGbwDvB7AAAAAZBm+A7wewAAAAGQZoAO8HsAAAABkGaIDvB7AAAAAZBmkA7wewAAAAGQZpgO8HsAAAABkGagDvB7AAAAAZBmqA7wewAAAAGQZrAO8HsAAAABkGa4DvB7AAAAAZBmwA7wewAAAAGQZsgO8HsAAAAOWWIggEfJigADYjJycnJycnJycnXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXgAAAAZBmjgd4PYAAAAHQZpUB3g9gAAAAAZBmmA7wewAAAAGQZqAO8HsAAAABkGaoDvB7AAAAAZBmsA7wewAAAAGQZrgO8HsAAAABkGbADvB7AAAAAZBmyA7wewAAAAGQZtAO8HsAAAABkGbYDvB7AAAAAZBm4A7wewAAAAGQZugO8HsAAAABkGbwDvB7AAAAAZBm+A7wewAAAAGQZoAO8HsAAAABkGaIDvB7AAAAAZBmkA7wewAAAAGQZpgO8HsAAAABkGagDvB7AAAAAZBmqA7wewAAAAGQZrAO8HsAAAABkGa4DvB7AAAAAZBmwA7wewAAAAGQZsgO8HsAAAABkGbQDvB7AAAAAZBm2A7wewAAAAGQZuAO8HsAAAABkGboDvB7AAAAAZBm8A7wewAAAAGQZvgO8HsAAAABkGaADvB7AAAAAZBmiA7wewAAAAGQZpAO8HsAAAABkGaYDvB7AAAAAZBmoA7wewAAAAGQZqgO8HsAAAABkGawDvB7AAAAAZBmuA7wewAAAAGQZsAO8HsAAAABkGbIDvB7AAAAAZBm0A7wewAAAAGQZtgO8HsAAAABkGbgDvB7AAAAAZBm6A7wewAAAAGQZvAO8HsAAAABkGb4DvB7AAAAAZBmgA7wewAAAAGQZogO8HsAAAABkGaQDvB7AAAAAZBmmA7wewAAAAGQZqAO8HsAAAABkGaoDvB7AAAAAZBmsA7wewAAAAGQZrgO8HsAAAABkGbADvB7AAAAAZBmyA7wewAAAAGQZtAO8HsAAAABkGbYDvB7AAAAAZBm4A7wewAAAAGQZugO8HsAAAABkGbwDvB7AAAAAZBm+A7wewAAAAGQZoAO8HsAAAABkGaIDvB7AAAAAZBmkA7wewAAAAGQZpgO8HsAAAABkGagDvB7AAAAAZBmqA7wewAAAAGQZrAO8HsAAAABkGa4DvB7AAAAAZBmwA7wewAAAAGQZsgO8HsAAAABkGbQDvB7AAAAAZBm2A7wewAAAAGQZuAO8HsAAAABkGboDvB7AAAAAZBm8A7wewAAAAGQZvgO8HsAAAABkGaADvB7AAAAAZBmiA7wewAAAAGQZpAO8HsAAAABkGaYDvB7AAAAAZBmoA7wewAAAAGQZqgO8HsAAAABkGawDvB7AAAAAZBmuA7wewAAAAGQZsAO8HsAAAABkGbIDvB7AAAAAZBm0A7wewAAAAGQZtgO8HsAAAABkGbgDvB7AAAAAZBm6A7wewAAAAGQZvAO8HsAAAABkGb4DvB7AAAAAZBmgA7wewAAAAGQZogO8HsAAAABkGaQDvB7AAAAAZBmmA7wewAAAAGQZqAO8HsAAAABkGaoDvB7AAAAAZBmsA7wewAAAAGQZrgO8HsAAAABkGbADvB7AAAAAZBmyA7wewAAAAGQZtAO8HsAAAABkGbYDvB7AAAAAZBm4A3wewAAAAGQZugM8Hs" +) + +def _build_wav() -> bytes: + sample_rate = 8_000 + frame_count = sample_rate * 2 + pcm = bytearray(frame_count * 2) + for index in range(frame_count): + sample = int(8_000 * math.sin(2 * math.pi * 440 * index / sample_rate)) + struct.pack_into(" None: + self.upstream = upstream.rstrip("/") + self.fault = fault + self.journal = journal + self._fault_injected = False + self._sequence = 0 + self._lock = threading.Lock() + if journal is not None: + journal.parent.mkdir(parents=True, exist_ok=True) + journal.write_text("", encoding="utf-8") + + def should_fault(self, path: str) -> bool: + predicate = _FAULT_PATHS.get(self.fault) + if predicate is None or not predicate(path): + return False + with self._lock: + if self._fault_injected: + return False + self._fault_injected = True + return True + + def record(self, *, method: str, path: str, status: int, kind: str) -> None: + if self.journal is None: + return + with self._lock: + self._sequence += 1 + event = { + "sequence": self._sequence, + "timestampMs": int(time.time() * 1000), + "kind": kind, + "method": method, + "path": urllib.parse.urlsplit(path).path, + "status": status, + } + with self.journal.open("a", encoding="utf-8") as output: + output.write(json.dumps(event, separators=(",", ":"), sort_keys=True) + "\n") + + +class JellyfinProxyHandler(BaseHTTPRequestHandler): + server_version = "PlezyJellyfinProxy/1.0" + + @property + def state(self) -> ProxyState: + return self.server.state # type: ignore[attr-defined] + + def do_GET(self) -> None: + self._proxy() + + def do_HEAD(self) -> None: + self._proxy() + + def do_POST(self) -> None: + self._proxy() + + def do_PUT(self) -> None: + self._proxy() + + def do_PATCH(self) -> None: + self._proxy() + + def do_DELETE(self) -> None: + self._proxy() + + def do_OPTIONS(self) -> None: + self._proxy() + + def _proxy(self) -> None: + if self.state.should_fault(self.path): + payload = json.dumps({"error": "temporary Maestro fault"}).encode("utf-8") + self.send_response(HTTPStatus.SERVICE_UNAVAILABLE) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + if self.command != "HEAD": + self.wfile.write(payload) + self.state.record(method=self.command, path=self.path, status=503, kind="fault") + return + + content_length = int(self.headers.get("Content-Length", "0")) + body = self.rfile.read(content_length) if content_length else None + headers = { + name: value + for name, value in self.headers.items() + if name.lower() in _FORWARD_HEADERS + } + request = urllib.request.Request( + self.state.upstream + self.path, + data=body, + method=self.command, + headers=headers, + ) + try: + with urllib.request.urlopen(request, timeout=60) as response: + status = response.status + response_headers = response.headers + payload = response.read() + except urllib.error.HTTPError as error: + status = error.code + response_headers = error.headers + payload = error.read() + except (OSError, urllib.error.URLError) as error: + payload = json.dumps({"error": f"upstream unavailable: {error}"}).encode("utf-8") + status = HTTPStatus.BAD_GATEWAY + response_headers = {"Content-Type": "application/json"} + + self.send_response(status) + for name, value in response_headers.items(): + if name.lower() in _RESPONSE_HEADERS: + self.send_header(name, value) + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + if self.command != "HEAD": + try: + self.wfile.write(payload) + except (BrokenPipeError, ConnectionResetError): + pass + self.state.record(method=self.command, path=self.path, status=int(status), kind="request") + + def log_message(self, format: str, *args: Any) -> None: + print(f"jellyfin-proxy: {format % args}") + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, required=True) + parser.add_argument("--upstream", required=True) + parser.add_argument("--fault", choices=sorted(_FAULT_PATHS)) + parser.add_argument("--journal", type=Path) + return parser + + +def main() -> None: + args = _build_parser().parse_args() + server = ThreadingHTTPServer((args.host, args.port), JellyfinProxyHandler) + server.daemon_threads = True + server.state = ProxyState(args.upstream, args.fault, args.journal) # type: ignore[attr-defined] + print(f"Jellyfin proxy listening on http://{args.host}:{args.port} -> {args.upstream}", flush=True) + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + server.server_close() + + +if __name__ == "__main__": + main() diff --git a/scripts/maestro_real_jellyfin.py b/scripts/maestro_real_jellyfin.py new file mode 100755 index 00000000..2b0ae249 --- /dev/null +++ b/scripts/maestro_real_jellyfin.py @@ -0,0 +1,687 @@ +#!/usr/bin/env python3 +"""Prepare and bootstrap a disposable real Jellyfin server for Maestro.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import shutil +import sys +import time +from typing import Any +import urllib.error +import urllib.parse +import urllib.request +import xml.etree.ElementTree as ET + +from maestro_fixtures import MEDIA_FIXTURE_SPECS, _AUDIO, _VIDEO + +USERNAME = "maestro" +PASSWORD = "maestro" +GUEST_USERNAME = "guest" +GUEST_PASSWORD = "guest" +SERVER_NAME = "Maestro Jellyfin" +BASE_TITLE = "Maestro Movie" +BASE_OVERVIEW = "A deterministic movie used to verify Plezy's end-to-end flows." +GUEST_TITLE = "Guest Galaxy" +SHOW_TITLE = "Maestro Show" +EPISODE_TITLES = ("Maestro Episode 1", "Maestro Episode 2") +MUSIC_ARTIST = "Maestro Artist" +MUSIC_ALBUM = "Regression Album" +MUSIC_TRACK = "Resilient Track" +ALPHABET_TITLES = ( + "Alpha Archive", + "Bravo Beacon", + "Charlie Circuit", + "Delta Drive", + "Echo Engine", + "Foxtrot Frame", + "Gamma Garden", + "Hotel Horizon", + "India Index", + "Juliet Junction", + "Kilo Key", + "Lima Loop", + "Mike Matrix", + "November Node", + "Oscar Orbit", + "Papa Pipeline", + "Quebec Queue", + "Romeo Relay", + "Sierra Signal", + "Tango Track", + "Uniform Update", + "Victor View", + "Whiskey Widget", + "Xray XML", + "Yankee Yield", + "Zulu Zone", +) +_MANAGED_MARKER = ".plezy-jellyfin-e2e-media" +DEFAULT_CODEC_BASE_URL = "https://demo-files.plezy.app/media-samples/" +_DOWNLOAD_CHUNK_SIZE = 1024 * 1024 + + + +def _write_nfo(path: Path, *, item_id: str, title: str, overview: str, genre: str) -> None: + movie = ET.Element("movie") + values = { + "title": title, + "originaltitle": title, + "sorttitle": title, + "year": "2026", + "premiered": "2026-01-01", + "dateadded": "2026-01-01 00:00:00", + "plot": overview, + "outline": overview, + "studio": "Plezy E2E", + "genre": genre, + "tag": "E2E", + "mpaa": "E2E", + "rating": "8.0", + "lockdata": "true", + } + for key, value in values.items(): + ET.SubElement(movie, key).text = value + unique_id = ET.SubElement(movie, "uniqueid", {"type": "plezy", "default": "true"}) + unique_id.text = item_id + ET.indent(movie, space=" ") + ET.ElementTree(movie).write(path, encoding="utf-8", xml_declaration=True) + +def _write_show_nfo(path: Path) -> None: + show = ET.Element("tvshow") + for key, value in { + "title": SHOW_TITLE, + "sorttitle": SHOW_TITLE, + "year": "2026", + "premiered": "2026-01-01", + "plot": "A deterministic show used to verify episode playback and queue behavior.", + "studio": "Plezy E2E", + "genre": "Test", + "lockdata": "true", + }.items(): + ET.SubElement(show, key).text = value + unique_id = ET.SubElement(show, "uniqueid", {"type": "plezy", "default": "true"}) + unique_id.text = "maestro-show" + ET.indent(show, space=" ") + ET.ElementTree(show).write(path, encoding="utf-8", xml_declaration=True) + + +def _write_episode_nfo(path: Path, number: int) -> None: + episode = ET.Element("episodedetails") + title = EPISODE_TITLES[number - 1] + for key, value in { + "title": title, + "showtitle": SHOW_TITLE, + "season": "1", + "episode": str(number), + "aired": f"2026-01-0{number}", + "plot": f"Deterministic episode {number} for player queue coverage.", + "lockdata": "true", + }.items(): + ET.SubElement(episode, key).text = value + unique_id = ET.SubElement(episode, "uniqueid", {"type": "plezy", "default": "true"}) + unique_id.text = f"maestro-episode-{number}" + ET.indent(episode, space=" ") + ET.ElementTree(episode).write(path, encoding="utf-8", xml_declaration=True) + + +def _write_music_nfo(path: Path, root_name: str, values: dict[str, str]) -> None: + root = ET.Element(root_name) + for key, value in values.items(): + ET.SubElement(root, key).text = value + ET.indent(root, space=" ") + ET.ElementTree(root).write(path, encoding="utf-8", xml_declaration=True) + + +def _reset_managed_directory(path: Path) -> None: + marker = path / _MANAGED_MARKER + if not path.exists(): + path.mkdir(parents=True) + marker.write_text("Managed by scripts/maestro_real_jellyfin.py\n", encoding="utf-8") + return + if not marker.is_file(): + if any(path.iterdir()): + raise ValueError(f"Refusing to clear unmanaged media staging directory: {path}") + marker.write_text("Managed by scripts/maestro_real_jellyfin.py\n", encoding="utf-8") + return + for child in path.iterdir(): + if child == marker: + continue + if child.is_dir() and not child.is_symlink(): + shutil.rmtree(child) + else: + child.unlink() + + +def _hard_link(source: Path, destination: Path) -> None: + try: + os.link(source, destination) + except OSError as error: + raise ValueError( + f"Could not hard-link {source} into the staging directory; keep both paths on the same filesystem" + ) from error + + +def _codec_digest(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as source: + while chunk := source.read(_DOWNLOAD_CHUNK_SIZE): + digest.update(chunk) + return digest.hexdigest() + + +def _valid_codec_file(path: Path, expected_size: int, expected_sha256: str) -> bool: + return path.is_file() and path.stat().st_size == expected_size and _codec_digest(path) == expected_sha256 + + +def download_codec_media(output_dir: Path, base_url: str = DEFAULT_CODEC_BASE_URL) -> list[str]: + output = output_dir.expanduser().resolve() + output.mkdir(parents=True, exist_ok=True) + normalized_base_url = base_url.rstrip("/") + "/" + filenames: list[str] = [] + + for spec in MEDIA_FIXTURE_SPECS: + destination = output / spec.filename + filenames.append(spec.filename) + if _valid_codec_file(destination, spec.size_bytes, spec.sha256): + continue + + partial = destination.with_suffix(f"{destination.suffix}.part") + partial.unlink(missing_ok=True) + request = urllib.request.Request( + urllib.parse.urljoin(normalized_base_url, urllib.parse.quote(spec.filename)), + headers={"User-Agent": "plezy-jellyfin-demo-builder"}, + ) + try: + with urllib.request.urlopen(request, timeout=60) as response: + raw_length = response.headers.get("Content-Length") + if raw_length is not None and int(raw_length) != spec.size_bytes: + raise ValueError( + f"{spec.filename} download is {raw_length} bytes, expected {spec.size_bytes}" + ) + total = 0 + digest = hashlib.sha256() + with partial.open("wb") as target: + while chunk := response.read(_DOWNLOAD_CHUNK_SIZE): + total += len(chunk) + if total > spec.size_bytes: + raise ValueError(f"{spec.filename} download exceeds {spec.size_bytes} bytes") + digest.update(chunk) + target.write(chunk) + if total != spec.size_bytes: + raise ValueError(f"{spec.filename} download is {total} bytes, expected {spec.size_bytes}") + if digest.hexdigest() != spec.sha256: + raise ValueError(f"{spec.filename} download failed SHA-256 verification") + partial.replace(destination) + except (OSError, ValueError, urllib.error.URLError): + partial.unlink(missing_ok=True) + raise + + return filenames + + +def prepare_media(output_dir: Path, codec_source_dir: Path | None, include_codecs: bool) -> list[str]: + output = output_dir.expanduser().resolve() + _reset_managed_directory(output) + + base_dir = output / "movies" / "maestro-movie" + base_dir.mkdir(parents=True) + (base_dir / f"{BASE_TITLE}.mp4").write_bytes(_VIDEO) + _write_nfo( + base_dir / f"{BASE_TITLE}.nfo", + item_id="maestro-movie", + title=BASE_TITLE, + overview=BASE_OVERVIEW, + genre="Test", + ) + titles = [BASE_TITLE] + + for alphabet_title in ALPHABET_TITLES: + for copy in range(1, 5): + title = alphabet_title if copy == 1 else f"{alphabet_title} {copy}" + item_id = f"alpha-{title.lower().replace(' ', '-')}" + item_dir = output / "movies" / item_id + item_dir.mkdir() + media_path = item_dir / f"{title}.mp4" + media_path.write_bytes(_VIDEO) + _write_nfo( + media_path.with_suffix(".nfo"), + item_id=item_id, + title=title, + overview="A deterministic title for TV alphabet focus coverage.", + genre="E2E Alphabet", + ) + titles.append(title) + + guest_dir = output / "guest-movies" / "guest-galaxy" + guest_dir.mkdir(parents=True) + guest_media = guest_dir / f"{GUEST_TITLE}.mp4" + guest_media.write_bytes(_VIDEO) + _write_nfo( + guest_media.with_suffix(".nfo"), + item_id="guest-galaxy", + title=GUEST_TITLE, + overview="Content visible only to the Maestro Guest profile.", + genre="E2E Guest", + ) + + show_dir = output / "shows" / SHOW_TITLE + season_dir = show_dir / "Season 01" + season_dir.mkdir(parents=True) + _write_show_nfo(show_dir / "tvshow.nfo") + for number, title in enumerate(EPISODE_TITLES, start=1): + episode_path = season_dir / f"{SHOW_TITLE} S01E{number:02d} - {title}.mp4" + episode_path.write_bytes(_VIDEO) + _write_episode_nfo(episode_path.with_suffix(".nfo"), number) + + album_dir = output / "music" / MUSIC_ARTIST / MUSIC_ALBUM + album_dir.mkdir(parents=True) + (album_dir / f"{MUSIC_TRACK}.wav").write_bytes(_AUDIO) + _write_music_nfo( + album_dir.parent / "artist.nfo", + "artist", + {"name": MUSIC_ARTIST, "sortname": MUSIC_ARTIST, "overview": "Deterministic E2E music artist."}, + ) + _write_music_nfo( + album_dir / "album.nfo", + "album", + { + "title": MUSIC_ALBUM, + "artist": MUSIC_ARTIST, + "albumartist": MUSIC_ARTIST, + "year": "2026", + "review": "Deterministic E2E music album.", + }, + ) + + if not include_codecs: + return titles + if codec_source_dir is None: + raise ValueError("--codec-source-dir is required with --include-codecs") + + source_dir = codec_source_dir.expanduser().resolve() + missing = [spec.filename for spec in MEDIA_FIXTURE_SPECS if not (source_dir / spec.filename).is_file()] + if missing: + raise ValueError(f"Codec fixture directory is missing: {', '.join(missing)}") + + for spec in MEDIA_FIXTURE_SPECS: + item_dir = output / "movies" / spec.id + item_dir.mkdir() + media_path = item_dir / f"{spec.title}.mkv" + _hard_link(source_dir / spec.filename, media_path) + _write_nfo( + media_path.with_suffix(".nfo"), + item_id=spec.id, + title=spec.title, + overview=spec.overview, + genre="E2E Codec", + ) + titles.append(spec.title) + return titles + + +class JellyfinApi: + def __init__(self, base_url: str) -> None: + self.base_url = base_url.rstrip("/") + + def request( + self, + method: str, + path: str, + *, + payload: Any | None = None, + token: str | None = None, + headers: dict[str, str] | None = None, + timeout: float = 30, + ) -> tuple[int, bytes]: + request_headers = {"Accept": "application/json", **(headers or {})} + data: bytes | None = None + if payload is not None: + data = json.dumps(payload).encode("utf-8") + request_headers["Content-Type"] = "application/json" + elif method == "POST": + data = b"" + if token is not None: + request_headers["X-Emby-Token"] = token + request = urllib.request.Request(self.base_url + path, data=data, method=method, headers=request_headers) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + return response.status, response.read() + except urllib.error.HTTPError as error: + return error.code, error.read() + + def json(self, method: str, path: str, **kwargs: Any) -> Any: + status, body = self.request(method, path, **kwargs) + if status < 200 or status >= 300: + detail = body.decode("utf-8", errors="replace")[:500] + raise RuntimeError(f"Jellyfin {method} {path} returned HTTP {status}: {detail}") + return json.loads(body) if body else None + + +def _wait_until(deadline: float, description: str, operation: Any) -> Any: + last_error: Exception | None = None + while time.monotonic() < deadline: + try: + result = operation() + if result is not None: + return result + except (OSError, RuntimeError, urllib.error.URLError) as error: + last_error = error + time.sleep(1) + suffix = f": {last_error}" if last_error is not None else "" + raise TimeoutError(f"Timed out waiting for {description}{suffix}") + + +def bootstrap_server( + base_url: str, + expected_titles: set[str], + timeout_seconds: int, + expected_music_titles: set[str] | None = None, +) -> dict[str, Any]: + api = JellyfinApi(base_url) + deadline = time.monotonic() + timeout_seconds + + def public_info() -> dict[str, Any] | None: + status, body = api.request("GET", "/System/Info/Public", timeout=3) + if status != 200: + return None + return json.loads(body) + + info = _wait_until(deadline, "Jellyfin startup", public_info) + version = info.get("Version") or info.get("version") + if version != "10.11.11": + raise RuntimeError(f"Expected Jellyfin 10.11.11, got {version}") + + startup_complete = info.get("StartupWizardCompleted", info.get("startupWizardCompleted", False)) + if not startup_complete: + + def configure_startup() -> bool | None: + status, body = api.request( + "POST", + "/Startup/Configuration", + payload={ + "UICulture": "en-US", + "MetadataCountryCode": "US", + "PreferredMetadataLanguage": "en", + }, + ) + if status == 204: + return True + if status == 503: + return None + detail = body.decode("utf-8", errors="replace")[:500] + raise RuntimeError(f"Jellyfin startup configuration returned HTTP {status}: {detail}") + + _wait_until(deadline, "Jellyfin startup API", configure_startup) + + def startup_user() -> dict[str, Any] | None: + status, body = api.request("GET", "/Startup/User", timeout=3) + if status != 200: + return None + return json.loads(body) + + _wait_until(deadline, "Jellyfin startup user", startup_user) + api.json("POST", "/Startup/User", payload={"Name": USERNAME, "Password": PASSWORD}) + api.json("POST", "/Startup/Complete") + + authorization = ( + 'MediaBrowser Client="Plezy E2E Bootstrap", Device="Host", ' + 'DeviceId="plezy-e2e-bootstrap", Version="1.0"' + ) + + def authenticate(username: str, password: str) -> dict[str, Any] | None: + status, body = api.request( + "POST", + "/Users/AuthenticateByName", + payload={"Username": username, "Pw": password}, + headers={"Authorization": authorization}, + timeout=5, + ) + if status != 200: + return None + return json.loads(body) + + authentication = _wait_until(deadline, "Jellyfin authentication", lambda: authenticate(USERNAME, PASSWORD)) + token = authentication["AccessToken"] + user_id = authentication["User"]["Id"] + + configuration = api.json("GET", "/System/Configuration", token=token) + if configuration.get("ServerName") != SERVER_NAME: + configuration["ServerName"] = SERVER_NAME + api.json("POST", "/System/Configuration", payload=configuration, token=token) + + required_folders = ( + ("Maestro Movies", "movies", "/media/movies"), + ("Guest Movies", "movies", "/media/guest-movies"), + ("Maestro Shows", "tvshows", "/media/shows"), + ("Maestro Music", "music", "/media/music"), + ) + virtual_folders = api.json("GET", "/Library/VirtualFolders", token=token) + existing_folder_names = {folder.get("Name") for folder in virtual_folders} + for name, collection_type, path in required_folders: + if name in existing_folder_names: + continue + query = urllib.parse.urlencode( + { + "name": name, + "collectionType": collection_type, + "paths": path, + "refreshLibrary": "false", + } + ) + api.json("POST", f"/Library/VirtualFolders?{query}", token=token) + + users = api.json("GET", "/Users", token=token) + main_user = next(user for user in users if user["Id"] == user_id) + guest_user = next((user for user in users if user.get("Name") == GUEST_USERNAME), None) + if guest_user is None: + guest_user = api.json( + "POST", + "/Users/New", + payload={"Name": GUEST_USERNAME, "Password": GUEST_PASSWORD}, + token=token, + ) + if not guest_user.get("HasPassword", guest_user.get("HasConfiguredPassword", False)): + api.json( + "POST", + f"/Users/{guest_user['Id']}/Password", + payload={"CurrentPw": "", "NewPw": GUEST_PASSWORD}, + token=token, + ) + + unrestricted_policy = dict(main_user["Policy"]) + unrestricted_policy["EnableAllFolders"] = True + unrestricted_policy["EnabledFolders"] = [] + api.json("POST", f"/Users/{user_id}/Policy", payload=unrestricted_policy, token=token) + + views = api.json("GET", f"/Users/{user_id}/Views", token=token).get("Items", []) + views_by_name = {view.get("Name"): view.get("Id") for view in views} + missing_views = [name for name, _, _ in required_folders if not views_by_name.get(name)] + if missing_views: + raise RuntimeError(f"Jellyfin did not create library views: {', '.join(missing_views)}") + + main_policy = dict(main_user["Policy"]) + main_policy["EnableAllFolders"] = False + main_policy["EnabledFolders"] = [ + views_by_name["Maestro Movies"], + views_by_name["Maestro Shows"], + views_by_name["Maestro Music"], + ] + api.json("POST", f"/Users/{user_id}/Policy", payload=main_policy, token=token) + + guest_policy = dict(guest_user["Policy"]) + guest_policy["EnableAllFolders"] = False + guest_policy["EnabledFolders"] = [views_by_name["Guest Movies"]] + api.json("POST", f"/Users/{guest_user['Id']}/Policy", payload=guest_policy, token=token) + + api.json("POST", "/Library/Refresh", token=token) + + def scanned_items() -> list[dict[str, Any]] | None: + query = urllib.parse.urlencode( + { + "Recursive": "true", + "IncludeItemTypes": "Movie", + "Fields": "MediaSources,MediaStreams", + "Limit": "500", + } + ) + result = api.json("GET", f"/Users/{user_id}/Items?{query}", token=token) + items = result.get("Items", []) + by_title = {item.get("Name"): item for item in items} + if not expected_titles.issubset(by_title): + return None + if any(not by_title[title].get("MediaSources") for title in expected_titles): + return None + if GUEST_TITLE in by_title: + raise RuntimeError("Main Jellyfin user can see the guest-only library") + return items + + items = _wait_until(deadline, "Jellyfin media scan", scanned_items) + required_music = expected_music_titles or {MUSIC_TRACK} + + def scanned_music() -> list[dict[str, Any]] | None: + query = urllib.parse.urlencode( + { + "Recursive": "true", + "IncludeItemTypes": "Audio", + "Fields": "MediaSources,MediaStreams,Album,AlbumId,AlbumArtist,AlbumArtists", + "Limit": "100", + } + ) + result = api.json("GET", f"/Users/{user_id}/Items?{query}", token=token) + music_items = result.get("Items", []) + by_title = {item.get("Name"): item for item in music_items} + if not required_music.issubset(by_title): + return None + if any(not by_title[title].get("MediaSources") for title in required_music): + return None + return music_items + + music_items = _wait_until(deadline, "Jellyfin music scan", scanned_music) + + def scanned_artists() -> list[dict[str, Any]] | None: + query = urllib.parse.urlencode({"UserId": user_id, "Limit": "100"}) + result = api.json("GET", f"/Artists/AlbumArtists?{query}", token=token) + artists = result.get("Items", []) + if MUSIC_ARTIST not in {artist.get("Name") for artist in artists}: + return None + return artists + + artist_items = _wait_until(deadline, "Jellyfin music artist scan", scanned_artists) + + def scanned_episodes() -> list[dict[str, Any]] | None: + query = urllib.parse.urlencode( + { + "Recursive": "true", + "IncludeItemTypes": "Episode", + "Fields": "MediaSources,MediaStreams,SeriesId,ParentId,IndexNumber", + "Limit": "100", + } + ) + result = api.json("GET", f"/Users/{user_id}/Items?{query}", token=token) + episodes = result.get("Items", []) + by_title = {item.get("Name"): item for item in episodes} + if not set(EPISODE_TITLES).issubset(by_title): + return None + if any(not by_title[title].get("MediaSources") for title in EPISODE_TITLES): + return None + return episodes + + episode_items = _wait_until(deadline, "Jellyfin episode scan", scanned_episodes) + guest_authentication = _wait_until( + deadline, + "Jellyfin guest authentication", + lambda: authenticate(GUEST_USERNAME, GUEST_PASSWORD), + ) + guest_token = guest_authentication["AccessToken"] + + def scanned_guest_items() -> list[dict[str, Any]] | None: + query = urllib.parse.urlencode( + { + "Recursive": "true", + "IncludeItemTypes": "Movie", + "Fields": "MediaSources,MediaStreams", + "Limit": "100", + } + ) + result = api.json("GET", f"/Users/{guest_user['Id']}/Items?{query}", token=guest_token) + guest_items = result.get("Items", []) + by_title = {item.get("Name"): item for item in guest_items} + if GUEST_TITLE not in by_title or not by_title[GUEST_TITLE].get("MediaSources"): + return None + if BASE_TITLE in by_title: + raise RuntimeError("Guest Jellyfin user can see the main library") + return guest_items + + guest_items = _wait_until(deadline, "Jellyfin guest media scan", scanned_guest_items) + return { + "server": SERVER_NAME, + "version": "10.11.11", + "userId": user_id, + "guestUserId": guest_user["Id"], + "titles": sorted(item["Name"] for item in items if item.get("Name") in expected_titles), + "musicTitles": sorted(item["Name"] for item in music_items if item.get("Name") in required_music), + "artistTitles": sorted(item["Name"] for item in artist_items if item.get("Name") == MUSIC_ARTIST), + "episodeTitles": sorted(item["Name"] for item in episode_items if item.get("Name") in EPISODE_TITLES), + "guestTitles": sorted(item["Name"] for item in guest_items if item.get("Name") == GUEST_TITLE), + } + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + subparsers = parser.add_subparsers(dest="command", required=True) + + prepare = subparsers.add_parser("prepare", help="Create the deterministic media staging tree") + prepare.add_argument("--output-dir", type=Path, required=True) + prepare.add_argument("--codec-source-dir", type=Path) + prepare.add_argument("--include-codecs", action="store_true") + + download = subparsers.add_parser("download-codecs", help="Download and verify the hosted codec fixtures") + download.add_argument("--output-dir", type=Path, required=True) + download.add_argument( + "--base-url", + default=os.environ.get("PLEZY_DEMO_MEDIA_BASE_URL", DEFAULT_CODEC_BASE_URL), + ) + + bootstrap = subparsers.add_parser("bootstrap", help="Configure and verify a fresh Jellyfin server") + bootstrap.add_argument("--url", required=True) + bootstrap.add_argument("--expected-title", action="append", default=[]) + bootstrap.add_argument("--expected-music-title", action="append", default=[]) + bootstrap.add_argument("--timeout", type=int, default=600) + bootstrap.add_argument("--include-codecs", action="store_true") + return parser + + +def main() -> int: + args = _build_parser().parse_args() + try: + if args.command == "download-codecs": + filenames = download_codec_media(args.output_dir, args.base_url) + print(json.dumps({"codecDir": str(args.output_dir.resolve()), "files": filenames}, sort_keys=True)) + elif args.command == "prepare": + titles = prepare_media(args.output_dir, args.codec_source_dir, args.include_codecs) + print(json.dumps({"mediaDir": str(args.output_dir.resolve()), "titles": titles}, sort_keys=True)) + else: + expected = {BASE_TITLE, *args.expected_title} + expected.update( + title if copy == 1 else f"{title} {copy}" + for title in ALPHABET_TITLES + for copy in range(1, 5) + ) + if args.include_codecs: + expected.update(spec.title for spec in MEDIA_FIXTURE_SPECS) + expected_music = set(args.expected_music_title) or {MUSIC_TRACK} + result = bootstrap_server(args.url, expected, args.timeout, expected_music) + print(json.dumps(result, sort_keys=True)) + return 0 + except (OSError, RuntimeError, TimeoutError, ValueError) as error: + print(f"Real Jellyfin E2E setup failed: {error}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/prepare_maestro_media.py b/scripts/prepare_maestro_media.py new file mode 100755 index 00000000..0497dde9 --- /dev/null +++ b/scripts/prepare_maestro_media.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +"""Remux local codec fixtures into long-running copies for interactive E2E flows.""" + +from __future__ import annotations + +import argparse +import json +import math +from pathlib import Path +import shutil +import subprocess +import sys + +from maestro_fixtures import MEDIA_FIXTURE_SPECS + + +def _duration(path: Path) -> float: + result = subprocess.run( + [ + "ffprobe", + "-v", + "error", + "-show_entries", + "format=duration", + "-of", + "json", + str(path), + ], + check=True, + capture_output=True, + text=True, + ) + payload = json.loads(result.stdout) + try: + duration = float(payload["format"]["duration"]) + except (KeyError, TypeError, ValueError) as error: + raise ValueError(f"ffprobe returned no duration for {path}") from error + if duration <= 0: + raise ValueError(f"media duration must be positive: {path}") + return duration + + +def prepare_media( + source_directory: Path, + output_directory: Path, + *, + duration: float, + extend_filenames: frozenset[str] | None = None, +) -> None: + if duration <= 0: + raise ValueError("target duration must be positive") + if shutil.which("ffmpeg") is None or shutil.which("ffprobe") is None: + raise RuntimeError("ffmpeg and ffprobe are required for the local codec suite") + + source_directory = source_directory.expanduser().resolve() + output_directory = output_directory.expanduser().resolve() + output_directory.mkdir(parents=True, exist_ok=True) + + for spec in MEDIA_FIXTURE_SPECS: + source = source_directory / spec.filename + if not source.is_file(): + raise FileNotFoundError(f"missing codec fixture: {source}") + output = output_directory / spec.filename + if extend_filenames is not None and spec.filename not in extend_filenames: + shutil.copy2(source, output) + continue + if output.is_file() and output.stat().st_mtime_ns >= source.stat().st_mtime_ns: + try: + if _duration(output) >= duration: + print(f"Reusing {output.name}") + continue + except (subprocess.CalledProcessError, ValueError): + pass + + source_duration = _duration(source) + remux_duration = duration + 5 + repeat_count = max(0, math.ceil(remux_duration / source_duration) - 1) + temporary = output.with_name(f"{output.stem}.tmp{output.suffix}") + temporary.unlink(missing_ok=True) + print(f"Preparing {output.name} ({source_duration:.1f}s -> {duration:.1f}s)") + try: + subprocess.run( + [ + "ffmpeg", + "-y", + "-v", + "error", + "-stream_loop", + str(repeat_count), + "-i", + str(source), + "-map", + "0", + "-c", + "copy", + "-t", + str(remux_duration), + str(temporary), + ], + check=True, + ) + if _duration(temporary) < duration: + raise ValueError(f"prepared fixture is shorter than {duration}s: {temporary}") + temporary.replace(output) + finally: + temporary.unlink(missing_ok=True) + + +def _parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("source", type=Path, help="Directory containing the six untracked codec fixtures") + parser.add_argument("output", type=Path, help="Directory for derived long-running fixtures") + parser.add_argument("--duration", type=float, default=300, help="Minimum output duration in seconds") + parser.add_argument( + "--extend", + action="append", + choices=[spec.filename for spec in MEDIA_FIXTURE_SPECS], + dest="extend_filenames", + help="Only extend this fixture; may be repeated. By default every fixture is extended.", + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = _parse_args(sys.argv[1:] if argv is None else argv) + try: + extend_filenames = frozenset(args.extend_filenames) if args.extend_filenames is not None else None + prepare_media( + args.source, + args.output, + duration=args.duration, + extend_filenames=extend_filenames, + ) + except (FileNotFoundError, RuntimeError, ValueError, subprocess.CalledProcessError) as error: + print(f"Codec fixture preparation failed: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_maestro.py b/scripts/run_maestro.py new file mode 100755 index 00000000..387d62bb --- /dev/null +++ b/scripts/run_maestro.py @@ -0,0 +1,739 @@ +#!/usr/bin/env python3 +"""Build the Jellyfin fixture and run Plezy's Android Maestro suites.""" + +from __future__ import annotations + +import argparse +from dataclasses import dataclass +import os +from pathlib import Path +import shlex +import signal +import shutil +import subprocess +import sys +import time +from typing import Mapping, Optional, Sequence, TextIO +import urllib.error +import urllib.request + + +ROOT_DIR = Path(__file__).resolve().parent.parent +APP_ID = "com.edde746.plezy" +FAULTS = ("music-failure", "recovery") + + +class RunnerError(RuntimeError): + """A user-actionable runner failure.""" + +class RunnerSignal(Exception): + def __init__(self, signum: int) -> None: + super().__init__(f"Interrupted by signal {signum}") + self.exit_status = 128 + signum + + +def _raise_signal(signum: int, _frame: object) -> None: + raise RunnerSignal(signum) + + + + +@dataclass(frozen=True) +class SuitePreset: + flow_target: str + maestro_config: Optional[str] + jellyfin_log: str + diagnostics_dir: str + use_adb_reverse: bool = False + uninstall_before_install: bool = False + + +SUITES = { + "basic": SuitePreset( + flow_target=".maestro", + maestro_config=None, + jellyfin_log="build/maestro/jellyfin.log", + diagnostics_dir="build/maestro/diagnostics", + ), + "catalog": SuitePreset( + flow_target=".maestro/real_flows", + maestro_config=None, + jellyfin_log="build/maestro-real-jellyfin/jellyfin.log", + diagnostics_dir="build/maestro-real-jellyfin/diagnostics", + uninstall_before_install=True, + ), + "media": SuitePreset( + flow_target=".maestro/media_flows", + maestro_config=".maestro/media-config.yaml", + jellyfin_log="build/maestro-media/jellyfin.log", + diagnostics_dir="build/maestro/diagnostics", + use_adb_reverse=True, + uninstall_before_install=True, + ), +} + + +@dataclass(frozen=True) +class RunnerConfig: + command: str + jellyfin_host: str + jellyfin_port: int + proxy_port: int + jellyfin_image: str + skip_jellyfin: bool + skip_jellyfin_build: bool + skip_build: bool + jellyfin_fault: Optional[str] + use_adb_reverse: bool + device_id: Optional[str] + apk_path: Path + flow_target: Path + maestro_config: Optional[Path] + uninstall_before_install: bool + diagnostics_dir: Path + jellyfin_log: Path + proxy_log: Path + proxy_journal: Path + host_jellyfin_url: str + jellyfin_url: Optional[str] + jellyfin_build_attempts: int + + +def _positive_int(value: str) -> int: + try: + parsed = int(value) + except ValueError as error: + raise argparse.ArgumentTypeError("must be a positive integer") from error + if parsed < 1: + raise argparse.ArgumentTypeError("must be a positive integer") + return parsed + + +def _add_bool_argument(parser: argparse.ArgumentParser, name: str, *, destination: str) -> None: + group = parser.add_mutually_exclusive_group() + group.add_argument(f"--{name}", dest=destination, action="store_true") + group.add_argument(f"--no-{name}", dest=destination, action="store_false") + parser.set_defaults(**{destination: None}) + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "command", + choices=(*SUITES, "build-image"), + nargs="?", + default="basic", + help="suite to run, or build-image to only build the Jellyfin fixture", + ) + parser.add_argument("--device", dest="device_id") + parser.add_argument("--apk", dest="apk_path") + parser.add_argument("--flow", dest="flow_target") + parser.add_argument("--config", dest="maestro_config") + parser.add_argument("--fault", choices=FAULTS, dest="jellyfin_fault") + parser.add_argument("--jellyfin-host") + parser.add_argument("--jellyfin-port", type=int) + parser.add_argument("--proxy-port", type=int) + parser.add_argument("--jellyfin-image") + parser.add_argument("--diagnostics-dir") + parser.add_argument("--jellyfin-log") + parser.add_argument("--proxy-log") + parser.add_argument("--proxy-journal") + parser.add_argument("--host-jellyfin-url") + parser.add_argument("--jellyfin-url") + parser.add_argument("--jellyfin-build-attempts", type=_positive_int) + _add_bool_argument(parser, "skip-jellyfin", destination="skip_jellyfin") + _add_bool_argument(parser, "skip-jellyfin-build", destination="skip_jellyfin_build") + _add_bool_argument(parser, "skip-build", destination="skip_build") + _add_bool_argument(parser, "adb-reverse", destination="use_adb_reverse") + _add_bool_argument(parser, "uninstall-before-install", destination="uninstall_before_install") + return parser + + +def _env_bool(environment: Mapping[str, str], name: str, default: bool) -> bool: + value = environment.get(name) + if value is None: + return default + normalized = value.strip().lower() + if normalized in {"1", "true", "yes", "on"}: + return True + if normalized in {"0", "false", "no", "off"}: + return False + raise RunnerError(f"{name} must be one of 1, 0, true, false, yes, no, on, or off") + + +def _option(cli_value: object, environment: Mapping[str, str], name: str, default: object) -> object: + if cli_value is not None: + return cli_value + return environment.get(name, default) +def _int_option( + cli_value: Optional[int], + environment: Mapping[str, str], + name: str, + default: int, +) -> int: + value = _option(cli_value, environment, name, default) + try: + parsed = int(value) + except (TypeError, ValueError) as error: + raise RunnerError(f"{name} must be a positive integer") from error + if parsed < 1: + raise RunnerError(f"{name} must be a positive integer") + return parsed + + + + +def _bool_option( + cli_value: Optional[bool], + environment: Mapping[str, str], + name: str, + default: bool, +) -> bool: + if cli_value is not None: + return cli_value + return _env_bool(environment, name, default) + + +def _root_path(value: object) -> Path: + path = Path(str(value)) + return path if path.is_absolute() else ROOT_DIR / path + + +def parse_config(argv: Optional[Sequence[str]] = None, environment: Optional[Mapping[str, str]] = None) -> RunnerConfig: + args = _parser().parse_args(argv) + env = os.environ if environment is None else environment + preset = SUITES.get(args.command, SUITES["basic"]) + + jellyfin_host = str(_option(args.jellyfin_host, env, "MAESTRO_JELLYFIN_HOST", "127.0.0.1")) + jellyfin_port = _int_option(args.jellyfin_port, env, "MAESTRO_JELLYFIN_PORT", 8096) + proxy_port = _int_option(args.proxy_port, env, "MAESTRO_JELLYFIN_PROXY_PORT", jellyfin_port + 1) + config_value = _option(args.maestro_config, env, "MAESTRO_CONFIG", preset.maestro_config or "") + fault_value = _option(args.jellyfin_fault, env, "MAESTRO_JELLYFIN_FAULT", "") + fault = str(fault_value) or None + if fault is not None and fault not in FAULTS: + raise RunnerError(f"Unsupported MAESTRO_JELLYFIN_FAULT: {fault}") + + build_attempts = _int_option( + args.jellyfin_build_attempts, + env, + "MAESTRO_JELLYFIN_BUILD_ATTEMPTS", + 2, + ) + + return RunnerConfig( + command=args.command, + jellyfin_host=jellyfin_host, + jellyfin_port=jellyfin_port, + proxy_port=proxy_port, + jellyfin_image=str( + _option(args.jellyfin_image, env, "MAESTRO_JELLYFIN_IMAGE", "plezy-jellyfin-demo:local") + ), + skip_jellyfin=_bool_option(args.skip_jellyfin, env, "MAESTRO_SKIP_JELLYFIN", False), + skip_jellyfin_build=_bool_option( + args.skip_jellyfin_build, + env, + "MAESTRO_SKIP_JELLYFIN_BUILD", + False, + ), + skip_build=_bool_option(args.skip_build, env, "MAESTRO_SKIP_BUILD", False), + jellyfin_fault=fault, + use_adb_reverse=_bool_option( + args.use_adb_reverse, + env, + "MAESTRO_USE_ADB_REVERSE", + preset.use_adb_reverse, + ), + device_id=str(_option(args.device_id, env, "MAESTRO_DEVICE_ID", "")) or None, + apk_path=_root_path( + _option( + args.apk_path, + env, + "MAESTRO_APK_PATH", + "build/app/outputs/flutter-apk/app-debug.apk", + ) + ), + flow_target=_root_path( + _option(args.flow_target, env, "MAESTRO_FLOW_TARGET", preset.flow_target) + ), + maestro_config=_root_path(config_value) if config_value else None, + uninstall_before_install=_bool_option( + args.uninstall_before_install, + env, + "MAESTRO_UNINSTALL_BEFORE_INSTALL", + preset.uninstall_before_install, + ), + diagnostics_dir=_root_path( + _option(args.diagnostics_dir, env, "MAESTRO_DIAGNOSTICS_DIR", preset.diagnostics_dir) + ), + jellyfin_log=_root_path( + _option(args.jellyfin_log, env, "MAESTRO_JELLYFIN_LOG", preset.jellyfin_log) + ), + proxy_log=_root_path( + _option( + args.proxy_log, + env, + "MAESTRO_JELLYFIN_PROXY_LOG", + "build/maestro/jellyfin-proxy.log", + ) + ), + proxy_journal=_root_path( + _option( + args.proxy_journal, + env, + "MAESTRO_JELLYFIN_PROXY_JOURNAL", + "build/maestro/jellyfin-proxy-journal.jsonl", + ) + ), + host_jellyfin_url=str( + _option( + args.host_jellyfin_url, + env, + "MAESTRO_JELLYFIN_HOST_URL", + f"http://{jellyfin_host}:{jellyfin_port}", + ) + ), + jellyfin_url=str(_option(args.jellyfin_url, env, "MAESTRO_JELLYFIN_URL", "")) or None, + jellyfin_build_attempts=build_attempts, + ) + + +def _format_command(command: Sequence[object]) -> str: + values = [str(value) for value in command] + if os.name == "nt": + return subprocess.list2cmdline(values) + return shlex.join(values) + + +def _run_checked(command: Sequence[object], **kwargs: object) -> subprocess.CompletedProcess[str]: + values = [str(value) for value in command] + print(f"+ {_format_command(values)}", flush=True) + return subprocess.run(values, cwd=ROOT_DIR, check=True, text=True, **kwargs) + + +def _require_commands(names: Sequence[str]) -> None: + missing = [name for name in names if shutil.which(name) is None] + if missing: + raise RunnerError(f"Required command not found: {', '.join(missing)}") + + +def build_jellyfin_image(config: RunnerConfig) -> None: + _require_commands(("docker",)) + command = ( + "docker", + "build", + "--file", + ROOT_DIR / ".maestro/jellyfin-demo/Dockerfile", + "--tag", + config.jellyfin_image, + ROOT_DIR, + ) + for attempt in range(1, config.jellyfin_build_attempts + 1): + try: + _run_checked(command) + return + except subprocess.CalledProcessError: + if attempt == config.jellyfin_build_attempts: + raise RunnerError( + f"Jellyfin image build failed after {config.jellyfin_build_attempts} attempts" + ) + print(f"Jellyfin image build attempt {attempt} failed; retrying", file=sys.stderr) + time.sleep(5) + + +class MaestroRunner: + def __init__(self, config: RunnerConfig) -> None: + self.config = config + self.device_id = config.device_id + self.container_name: Optional[str] = None + self.proxy_process: Optional[subprocess.Popen[str]] = None + self.proxy_output: Optional[TextIO] = None + self.reverse_configured = False + self.device_service_port = config.jellyfin_port + self.host_jellyfin_url = config.host_jellyfin_url + self.device_settings: dict[tuple[str, str], str] = {} + + @property + def adb_prefix(self) -> list[str]: + prefix = ["adb"] + if self.device_id: + prefix.extend(("-s", self.device_id)) + return prefix + + def run(self) -> None: + required = ["adb", "maestro"] + if not self.config.skip_build: + required.append("flutter") + if not self.config.skip_jellyfin: + required.append("docker") + _require_commands(required) + self._select_device() + self._prepare_output_directories() + + if not self.config.skip_jellyfin: + if not self.config.skip_jellyfin_build: + build_jellyfin_image(self.config) + self._start_jellyfin() + self._wait_for_health(self.host_jellyfin_url, attempts=120, interval=0.25, service="Jellyfin") + + if self.config.jellyfin_fault: + self._start_proxy() + + if not self.config.skip_build: + _run_checked(("flutter", "pub", "get")) + _run_checked(("flutter", "build", "apk", "--debug")) + + self._prepare_device() + _run_checked((*self.adb_prefix, "install", "-r", self.config.apk_path)) + _run_checked(self.maestro_command()) + + def maestro_command(self) -> list[str]: + default_url = f"http://10.0.2.2:{self.device_service_port}" + if self.config.use_adb_reverse: + default_url = f"http://127.0.0.1:{self.device_service_port}" + jellyfin_url = self.config.jellyfin_url or default_url + command = ["maestro", "test", "-e", f"JELLYFIN_URL={jellyfin_url}"] + if self.device_id: + command.extend(("--device", self.device_id)) + if self.config.maestro_config: + command.extend(("--config", str(self.config.maestro_config))) + command.append(str(self.config.flow_target)) + return command + + def _prepare_output_directories(self) -> None: + for path in ( + self.config.diagnostics_dir, + self.config.jellyfin_log.parent, + self.config.proxy_log.parent, + self.config.proxy_journal.parent, + ): + path.mkdir(parents=True, exist_ok=True) + + def _select_device(self) -> None: + if not self.config.use_adb_reverse or self.device_id: + return + result = subprocess.run( + ("adb", "devices"), + cwd=ROOT_DIR, + check=True, + capture_output=True, + text=True, + ) + devices = [] + for line in result.stdout.splitlines()[1:]: + fields = line.split() + if len(fields) >= 2 and fields[1] == "device": + devices.append(fields[0]) + if len(devices) != 1: + raise RunnerError("Set --device to the Android device serial when using --adb-reverse") + self.device_id = devices[0] + + def _start_jellyfin(self) -> None: + name = f"plezy-maestro-jellyfin-{self.config.jellyfin_port}-{os.getpid()}" + result = _run_checked( + ( + "docker", + "run", + "--detach", + "--rm", + "--name", + name, + "--publish", + f"{self.config.jellyfin_host}:{self.config.jellyfin_port}:8096", + self.config.jellyfin_image, + ), + capture_output=True, + ) + self.container_name = result.stdout.strip() or name + + def _wait_for_health(self, base_url: str, *, attempts: int, interval: float, service: str) -> None: + health_url = f"{base_url.rstrip('/')}/health" + last_error: Optional[Exception] = None + for _ in range(attempts): + try: + with urllib.request.urlopen(health_url, timeout=1) as response: + response.read() + return + except (OSError, urllib.error.URLError) as error: + last_error = error + time.sleep(interval) + raise RunnerError(f"{service} did not become ready at {base_url}: {last_error}") + + def _start_proxy(self) -> None: + self.proxy_output = self.config.proxy_log.open("w", encoding="utf-8") + self.proxy_process = subprocess.Popen( + ( + sys.executable, + str(ROOT_DIR / "scripts/maestro_jellyfin_proxy.py"), + "--host", + self.config.jellyfin_host, + "--port", + str(self.config.proxy_port), + "--upstream", + self.host_jellyfin_url, + "--fault", + self.config.jellyfin_fault or "", + "--journal", + str(self.config.proxy_journal), + ), + cwd=ROOT_DIR, + stdout=self.proxy_output, + stderr=subprocess.STDOUT, + text=True, + ) + proxy_url = f"http://{self.config.jellyfin_host}:{self.config.proxy_port}" + for _ in range(50): + if self.proxy_process.poll() is not None: + raise RunnerError(f"Jellyfin fault proxy exited early; see {self.config.proxy_log}") + try: + self._wait_for_health(proxy_url, attempts=1, interval=0, service="Jellyfin fault proxy") + self.host_jellyfin_url = proxy_url + self.device_service_port = self.config.proxy_port + return + except RunnerError: + time.sleep(0.1) + raise RunnerError(f"Jellyfin fault proxy did not become ready; see {self.config.proxy_log}") + + def _adb_run( + self, + *arguments: object, + check: bool = True, + quiet: bool = False, + timeout: int = 30, + ) -> subprocess.CompletedProcess[str]: + kwargs: dict[str, object] = {} + if quiet: + kwargs.update(stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + return subprocess.run( + [*self.adb_prefix, *(str(argument) for argument in arguments)], + cwd=ROOT_DIR, + check=check, + text=True, + timeout=timeout, + **kwargs, + ) + + def _adb_capture(self, *arguments: object) -> Optional[str]: + command = [*self.adb_prefix, *(str(argument) for argument in arguments)] + try: + result = subprocess.run( + command, + cwd=ROOT_DIR, + check=False, + capture_output=True, + text=True, + timeout=10, + ) + except subprocess.TimeoutExpired as error: + raise RunnerError(f"ADB command timed out: {_format_command(command)}") from error + return result.stdout.strip() if result.returncode == 0 else None + + def _prepare_device(self) -> None: + _run_checked((*self.adb_prefix, "wait-for-device"), timeout=60) + for namespace, key in ( + ("global", "stay_on_while_plugged_in"), + ("secure", "immersive_mode_confirmations"), + ("global", "hide_error_dialogs"), + ): + value = self._adb_capture("shell", "settings", "get", namespace, key) + if value is not None: + self.device_settings[(namespace, key)] = value + + self._adb_run( + "shell", + "settings", + "put", + "secure", + "immersive_mode_confirmations", + "confirmed", + check=False, + quiet=True, + ) + self._adb_run( + "shell", + "settings", + "put", + "global", + "hide_error_dialogs", + "1", + check=False, + quiet=True, + ) + self._adb_run("shell", "input", "keyevent", "KEYCODE_BACK", check=False, quiet=True) + _run_checked((*self.adb_prefix, "shell", "svc", "power", "stayon", "true")) + _run_checked((*self.adb_prefix, "shell", "input", "keyevent", "KEYCODE_WAKEUP")) + _run_checked((*self.adb_prefix, "shell", "wm", "dismiss-keyguard")) + + if self.config.use_adb_reverse: + _run_checked( + ( + *self.adb_prefix, + "reverse", + f"tcp:{self.device_service_port}", + f"tcp:{self.device_service_port}", + ) + ) + self.reverse_configured = True + if self.config.uninstall_before_install: + self._adb_run("uninstall", APP_ID, check=False, quiet=True) + + def collect_failure_diagnostics(self, exit_status: int) -> None: + try: + self.config.diagnostics_dir.mkdir(parents=True, exist_ok=True) + state_path = self.config.diagnostics_dir / "run-state.txt" + with state_path.open("w", encoding="utf-8") as output: + output.write(f"exit_status={exit_status}\n") + output.write(f"jellyfin_host_url={self.host_jellyfin_url}\n") + output.write(f"jellyfin_container={self.container_name or ''}\n") + output.write(f"jellyfin_fault={self.config.jellyfin_fault or ''}\n") + output.write(f"proxy_pid={self.proxy_process.pid if self.proxy_process else ''}\n") + try: + with urllib.request.urlopen( + f"{self.host_jellyfin_url.rstrip('/')}/health", + timeout=2, + ) as response: + output.write(response.read().decode(errors="replace")) + output.write("\n") + except Exception as error: # Diagnostics must not hide the original failure. + output.write(f"health_error={error}\n") + + if self.container_name: + self._write_command_output( + ("docker", "logs", self.container_name), + self.config.jellyfin_log, + ) + self._write_command_output(("adb", "devices", "-l"), self.config.diagnostics_dir / "adb-devices.txt") + for filename, arguments in ( + ("device-properties.txt", ("shell", "getprop")), + ("device-processes.txt", ("shell", "ps", "-A")), + ("device-activities.txt", ("shell", "dumpsys", "activity", "activities")), + ("device-windows.txt", ("shell", "dumpsys", "window", "windows")), + ("device-logcat.txt", ("logcat", "-d", "-v", "threadtime")), + ): + self._write_command_output( + (*self.adb_prefix, *arguments), + self.config.diagnostics_dir / filename, + ) + if os.name == "nt" and shutil.which("tasklist"): + self._write_command_output(("tasklist",), self.config.diagnostics_dir / "host-processes.txt") + elif shutil.which("ps"): + self._write_command_output(("ps", "-ef"), self.config.diagnostics_dir / "host-processes.txt") + if shutil.which("lsof"): + self._write_command_output( + ("lsof", "-nP", f"-iTCP:{self.device_service_port}"), + self.config.diagnostics_dir / "jellyfin-listeners.txt", + ) + except Exception as error: # Diagnostics are best effort. + print(f"Failed to collect Maestro diagnostics: {error}", file=sys.stderr) + + def _write_command_output(self, command: Sequence[object], path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8") as output: + subprocess.run( + [str(value) for value in command], + cwd=ROOT_DIR, + check=False, + stdout=output, + stderr=subprocess.STDOUT, + text=True, + timeout=15, + ) + + def _adb_best_effort(self, *arguments: object) -> None: + try: + self._adb_run(*arguments, check=False, quiet=True, timeout=5) + except (OSError, subprocess.SubprocessError): + pass + + def cleanup(self) -> None: + for (namespace, key), value in self.device_settings.items(): + operation = "delete" if value == "null" else "put" + arguments = ["shell", "settings", operation, namespace, key] + if operation == "put": + arguments.append(value) + self._adb_best_effort(*arguments) + + if self.reverse_configured: + self._adb_best_effort( + "reverse", + "--remove", + f"tcp:{self.device_service_port}", + ) + try: + if self.proxy_process: + self.proxy_process.terminate() + try: + self.proxy_process.wait(timeout=5) + except subprocess.TimeoutExpired: + self.proxy_process.kill() + self.proxy_process.wait() + finally: + if self.proxy_output: + self.proxy_output.close() + + if self.container_name: + try: + self._write_command_output( + ("docker", "logs", self.container_name), + self.config.jellyfin_log, + ) + except (OSError, subprocess.SubprocessError): + pass + try: + subprocess.run( + ("docker", "stop", "--time", "15", self.container_name), + cwd=ROOT_DIR, + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + text=True, + timeout=30, + ) + except (OSError, subprocess.SubprocessError): + pass + + +def run(config: RunnerConfig) -> int: + if config.command == "build-image": + build_jellyfin_image(config) + return 0 + + runner = MaestroRunner(config) + exit_status = 0 + try: + runner.run() + except KeyboardInterrupt: + exit_status = 130 + print("Maestro run interrupted", file=sys.stderr) + except RunnerSignal as error: + exit_status = error.exit_status + print(str(error), file=sys.stderr) + except subprocess.CalledProcessError as error: + exit_status = error.returncode or 1 + print(f"Command failed ({exit_status}): {_format_command(error.cmd)}", file=sys.stderr) + except (OSError, RunnerError) as error: + exit_status = 1 + print(error, file=sys.stderr) + finally: + if exit_status: + runner.collect_failure_diagnostics(exit_status) + runner.cleanup() + return exit_status + + +def main(argv: Optional[Sequence[str]] = None) -> int: + signal.signal(signal.SIGINT, _raise_signal) + signal.signal(signal.SIGTERM, _raise_signal) + try: + config = parse_config(argv) + return run(config) + except RunnerSignal as error: + return error.exit_status + except KeyboardInterrupt: + return 130 + except (OSError, RunnerError, subprocess.CalledProcessError) as error: + print(error, file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test_maestro_jellyfin_proxy.py b/scripts/test_maestro_jellyfin_proxy.py new file mode 100755 index 00000000..ff0e3408 --- /dev/null +++ b/scripts/test_maestro_jellyfin_proxy.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 + +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import json +from pathlib import Path +import sys +import tempfile +import threading +import unittest +import urllib.error +import urllib.request + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from maestro_jellyfin_proxy import JellyfinProxyHandler, ProxyState # noqa: E402 + + +class _UpstreamHandler(BaseHTTPRequestHandler): + requests: list[tuple[str, str, bytes, str | None, str | None]] = [] + + def do_GET(self) -> None: + self._respond() + + def do_POST(self) -> None: + self._respond() + + def _respond(self) -> None: + length = int(self.headers.get("Content-Length", "0")) + body = self.rfile.read(length) if length else b"" + self.requests.append( + ( + self.command, + self.path, + body, + self.headers.get("X-Emby-Token"), + self.headers.get("Accept-Encoding"), + ) + ) + payload = b"real jellyfin response" + self.send_response(206 if self.headers.get("Range") else 200) + self.send_header("Content-Type", "application/octet-stream") + self.send_header("Accept-Ranges", "bytes") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def log_message(self, format: str, *args: object) -> None: + pass + + +class JellyfinProxyTests(unittest.TestCase): + def setUp(self) -> None: + _UpstreamHandler.requests = [] + self.temp_dir = tempfile.TemporaryDirectory() + self.upstream = ThreadingHTTPServer(("127.0.0.1", 0), _UpstreamHandler) + self.upstream_thread = threading.Thread(target=self.upstream.serve_forever, daemon=True) + self.upstream_thread.start() + + def tearDown(self) -> None: + self.upstream.shutdown() + self.upstream.server_close() + self.upstream_thread.join(timeout=5) + self.temp_dir.cleanup() + + def _start_proxy(self, fault: str | None) -> tuple[ThreadingHTTPServer, threading.Thread, str, Path]: + journal = Path(self.temp_dir.name) / "journal.jsonl" + proxy = ThreadingHTTPServer(("127.0.0.1", 0), JellyfinProxyHandler) + proxy.daemon_threads = True + upstream_url = f"http://127.0.0.1:{self.upstream.server_port}" + proxy.state = ProxyState(upstream_url, fault, journal) # type: ignore[attr-defined] + thread = threading.Thread(target=proxy.serve_forever, daemon=True) + thread.start() + return proxy, thread, f"http://127.0.0.1:{proxy.server_port}", journal + + def _stop_proxy(self, proxy: ThreadingHTTPServer, thread: threading.Thread) -> None: + proxy.shutdown() + proxy.server_close() + thread.join(timeout=5) + + def test_forwards_methods_bodies_tokens_and_range_responses(self) -> None: + proxy, thread, base_url, _ = self._start_proxy(None) + try: + request = urllib.request.Request( + base_url + "/Items?id=movie", + data=b'{"played":true}', + method="POST", + headers={ + "X-Emby-Token": "token", + "Range": "bytes=0-9", + "Content-Type": "application/json", + "Accept-Encoding": "gzip", + }, + ) + with urllib.request.urlopen(request) as response: + self.assertEqual(response.status, 206) + self.assertEqual(response.headers["Accept-Ranges"], "bytes") + self.assertEqual(response.read(), b"real jellyfin response") + self.assertEqual( + _UpstreamHandler.requests, + [("POST", "/Items?id=movie", b'{"played":true}', "token", "identity")], + ) + finally: + self._stop_proxy(proxy, thread) + + def test_recovery_faults_only_the_first_video_stream_request(self) -> None: + proxy, thread, base_url, journal = self._start_proxy("recovery") + try: + with self.assertRaises(urllib.error.HTTPError) as first: + urllib.request.urlopen(base_url + "/Videos/movie/stream.mp4?Static=true") + self.assertEqual(first.exception.code, 503) + first.exception.close() + with urllib.request.urlopen(base_url + "/Videos/movie/stream.mp4?Static=true") as second: + self.assertEqual(second.status, 200) + self.assertEqual(len(_UpstreamHandler.requests), 1) + events = [json.loads(line) for line in journal.read_text(encoding="utf-8").splitlines()] + self.assertEqual([event["kind"] for event in events], ["fault", "request"]) + self.assertEqual( + [event["path"] for event in events], + ["/Videos/movie/stream.mp4", "/Videos/movie/stream.mp4"], + ) + finally: + self._stop_proxy(proxy, thread) + + def test_music_fault_does_not_affect_other_requests(self) -> None: + proxy, thread, base_url, _ = self._start_proxy("music-failure") + try: + with urllib.request.urlopen(base_url + "/Items") as response: + self.assertEqual(response.status, 200) + with self.assertRaises(urllib.error.HTTPError) as failure: + urllib.request.urlopen(base_url + "/Artists/AlbumArtists?UserId=user") + self.assertEqual(failure.exception.code, 503) + failure.exception.close() + with urllib.request.urlopen(base_url + "/Artists/AlbumArtists?UserId=user") as recovered: + self.assertEqual(recovered.status, 200) + finally: + self._stop_proxy(proxy, thread) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_maestro_real_jellyfin.py b/scripts/test_maestro_real_jellyfin.py new file mode 100755 index 00000000..71feb4ef --- /dev/null +++ b/scripts/test_maestro_real_jellyfin.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 + +import hashlib +import io + +from pathlib import Path +import sys +import tempfile +from unittest.mock import patch +import unittest +import xml.etree.ElementTree as ET + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from maestro_fixtures import MEDIA_FIXTURE_SPECS, MediaFixtureSpec # noqa: E402 +import maestro_real_jellyfin as real_jellyfin # noqa: E402 +from maestro_real_jellyfin import ( # noqa: E402 + ALPHABET_TITLES, + BASE_TITLE, + EPISODE_TITLES, + GUEST_TITLE, + download_codec_media, + prepare_media, +) + + +class PrepareMediaTests(unittest.TestCase): + def test_base_media_is_deterministic_and_repeatable(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + output = Path(temp_dir) / "media" + + titles = prepare_media(output, None, False) + self.assertEqual(titles[0], BASE_TITLE) + self.assertEqual(len(titles), 1 + len(ALPHABET_TITLES) * 4) + movie = output / "movies" / "maestro-movie" / f"{BASE_TITLE}.mp4" + nfo = movie.with_suffix(".nfo") + first_payload = movie.read_bytes() + self.assertGreater(len(first_payload), 0) + self.assertEqual(ET.parse(nfo).findtext("title"), BASE_TITLE) + self.assertEqual( + ET.parse(output / "guest-movies" / "guest-galaxy" / f"{GUEST_TITLE}.nfo").findtext("title"), + GUEST_TITLE, + ) + episode_nfo = output / "shows" / "Maestro Show" / "Season 01" / ( + f"Maestro Show S01E01 - {EPISODE_TITLES[0]}.nfo" + ) + self.assertEqual(ET.parse(episode_nfo).findtext("title"), EPISODE_TITLES[0]) + + (output / "obsolete").mkdir() + self.assertEqual(prepare_media(output, None, False), titles) + self.assertEqual(movie.read_bytes(), first_payload) + self.assertFalse((output / "obsolete").exists()) + + def test_codec_media_uses_hard_links_and_exact_titles(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + source = root / "source" + source.mkdir() + for spec in MEDIA_FIXTURE_SPECS: + (source / spec.filename).write_bytes(spec.id.encode("utf-8")) + + output = root / "media" + titles = prepare_media(output, source, True) + + self.assertEqual(titles[0], BASE_TITLE) + self.assertEqual(titles[-len(MEDIA_FIXTURE_SPECS) :], [spec.title for spec in MEDIA_FIXTURE_SPECS]) + for spec in MEDIA_FIXTURE_SPECS: + staged = output / "movies" / spec.id / f"{spec.title}.mkv" + self.assertTrue(staged.samefile(source / spec.filename)) + self.assertEqual(ET.parse(staged.with_suffix(".nfo")).findtext("title"), spec.title) + + def test_codec_download_verifies_size_and_sha256_then_reuses_file(self) -> None: + payload = b"deterministic codec payload" + spec = MediaFixtureSpec( + id="codec-test", + title="Codec Test", + filename="codec-test.mkv", + overview="Test fixture.", + video_codec="h264", + width=320, + height=180, + size_bytes=len(payload), + sha256=hashlib.sha256(payload).hexdigest(), + ) + + with tempfile.TemporaryDirectory() as temp_dir: + output = Path(temp_dir) + response = io.BytesIO(payload) + response.headers = {"Content-Length": str(len(payload))} + with ( + patch.object(real_jellyfin, "MEDIA_FIXTURE_SPECS", (spec,)), + patch.object(real_jellyfin.urllib.request, "urlopen", return_value=response) as urlopen, + ): + self.assertEqual(download_codec_media(output, "https://media.example/"), [spec.filename]) + self.assertEqual((output / spec.filename).read_bytes(), payload) + self.assertEqual(urlopen.call_count, 1) + + with ( + patch.object(real_jellyfin, "MEDIA_FIXTURE_SPECS", (spec,)), + patch.object( + real_jellyfin.urllib.request, + "urlopen", + side_effect=AssertionError("valid cached fixture must not be downloaded"), + ), + ): + self.assertEqual(download_codec_media(output, "https://media.example/"), [spec.filename]) + + def test_codec_download_rejects_corrupt_payload_without_leaving_partial_file(self) -> None: + payload = b"corrupt" + spec = MediaFixtureSpec( + id="codec-test", + title="Codec Test", + filename="codec-test.mkv", + overview="Test fixture.", + video_codec="h264", + width=320, + height=180, + size_bytes=len(payload), + sha256=hashlib.sha256(b"expected").hexdigest(), + ) + + with tempfile.TemporaryDirectory() as temp_dir: + output = Path(temp_dir) + response = io.BytesIO(payload) + response.headers = {"Content-Length": str(len(payload))} + with ( + patch.object(real_jellyfin, "MEDIA_FIXTURE_SPECS", (spec,)), + patch.object(real_jellyfin.urllib.request, "urlopen", return_value=response), + ): + with self.assertRaisesRegex(ValueError, "failed SHA-256 verification"): + download_codec_media(output, "https://media.example/") + + self.assertFalse((output / spec.filename).exists()) + self.assertFalse((output / f"{spec.filename}.part").exists()) + + + def test_existing_empty_directory_can_become_managed(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + output = Path(temp_dir) / "media" + output.mkdir() + + self.assertEqual(prepare_media(output, None, False)[0], BASE_TITLE) + self.assertTrue((output / "movies" / "maestro-movie" / f"{BASE_TITLE}.mp4").is_file()) + + def test_existing_unmanaged_directory_is_never_cleared(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + output = Path(temp_dir) / "media" + output.mkdir() + sentinel = output / "keep.txt" + sentinel.write_text("user data", encoding="utf-8") + + with self.assertRaisesRegex(ValueError, "unmanaged media staging directory"): + prepare_media(output, None, False) + + self.assertEqual(sentinel.read_text(encoding="utf-8"), "user data") + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_run_maestro.py b/scripts/test_run_maestro.py new file mode 100755 index 00000000..67d75725 --- /dev/null +++ b/scripts/test_run_maestro.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +from contextlib import redirect_stderr +from dataclasses import replace +import io +from pathlib import Path +import subprocess +import sys +import unittest +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import run_maestro # noqa: E402 + + +class ParseConfigTests(unittest.TestCase): + def test_basic_defaults(self) -> None: + config = run_maestro.parse_config([], {}) + + self.assertEqual(config.command, "basic") + self.assertEqual(config.flow_target, run_maestro.ROOT_DIR / ".maestro") + self.assertIsNone(config.maestro_config) + self.assertFalse(config.use_adb_reverse) + self.assertFalse(config.uninstall_before_install) + + def test_suite_presets_replace_shell_wrappers(self) -> None: + catalog = run_maestro.parse_config(["catalog"], {}) + media = run_maestro.parse_config(["media"], {}) + + self.assertEqual(catalog.flow_target, run_maestro.ROOT_DIR / ".maestro/real_flows") + self.assertEqual( + catalog.diagnostics_dir, + run_maestro.ROOT_DIR / "build/maestro-real-jellyfin/diagnostics", + ) + self.assertTrue(catalog.uninstall_before_install) + self.assertEqual(media.flow_target, run_maestro.ROOT_DIR / ".maestro/media_flows") + self.assertEqual(media.maestro_config, run_maestro.ROOT_DIR / ".maestro/media-config.yaml") + self.assertTrue(media.use_adb_reverse) + self.assertTrue(media.uninstall_before_install) + + def test_cli_options_override_compatible_environment_values(self) -> None: + config = run_maestro.parse_config( + [ + "media", + "--no-adb-reverse", + "--flow", + "custom/flow.yaml", + "--device", + "cli-device", + ], + { + "MAESTRO_USE_ADB_REVERSE": "1", + "MAESTRO_FLOW_TARGET": "environment/flow.yaml", + "MAESTRO_DEVICE_ID": "environment-device", + "MAESTRO_SKIP_BUILD": "true", + }, + ) + + self.assertFalse(config.use_adb_reverse) + self.assertEqual(config.flow_target, run_maestro.ROOT_DIR / "custom/flow.yaml") + self.assertEqual(config.device_id, "cli-device") + self.assertTrue(config.skip_build) + + def test_invalid_environment_values_fail_early(self) -> None: + with self.assertRaisesRegex(run_maestro.RunnerError, "MAESTRO_SKIP_BUILD"): + run_maestro.parse_config([], {"MAESTRO_SKIP_BUILD": "sometimes"}) + with self.assertRaisesRegex(run_maestro.RunnerError, "MAESTRO_JELLYFIN_PORT"): + run_maestro.parse_config([], {"MAESTRO_JELLYFIN_PORT": "invalid"}) + with self.assertRaisesRegex(run_maestro.RunnerError, "MAESTRO_JELLYFIN_BUILD_ATTEMPTS"): + run_maestro.parse_config([], {"MAESTRO_JELLYFIN_BUILD_ATTEMPTS": "0"}) + + +class CommandTests(unittest.TestCase): + def test_media_command_contains_resolved_preset_and_device_url(self) -> None: + config = run_maestro.parse_config(["media", "--device", "emulator-5554"], {}) + command = run_maestro.MaestroRunner(config).maestro_command() + + self.assertEqual( + command, + [ + "maestro", + "test", + "-e", + "JELLYFIN_URL=http://127.0.0.1:8096", + "--device", + "emulator-5554", + "--config", + str(run_maestro.ROOT_DIR / ".maestro/media-config.yaml"), + str(run_maestro.ROOT_DIR / ".maestro/media_flows"), + ], + ) + + def test_explicit_device_url_wins_over_network_mode(self) -> None: + config = run_maestro.parse_config( + ["basic", "--adb-reverse", "--jellyfin-url", "http://device.test:9000"], + {}, + ) + + command = run_maestro.MaestroRunner(config).maestro_command() + + self.assertIn("JELLYFIN_URL=http://device.test:9000", command) + + +class LifecycleTests(unittest.TestCase): + def test_runner_failure_collects_diagnostics_and_cleans_up(self) -> None: + config = run_maestro.parse_config([], {}) + with patch.object(run_maestro, "MaestroRunner") as runner_type: + runner = runner_type.return_value + runner.run.side_effect = run_maestro.RunnerError("failed") + with redirect_stderr(io.StringIO()): + exit_status = run_maestro.run(config) + + self.assertEqual(exit_status, 1) + runner.collect_failure_diagnostics.assert_called_once_with(1) + runner.cleanup.assert_called_once_with() + + def test_image_build_retries_once(self) -> None: + config = replace(run_maestro.parse_config(["build-image"], {}), jellyfin_build_attempts=2) + failure = subprocess.CalledProcessError(1, ["docker", "build"]) + success = subprocess.CompletedProcess(["docker", "build"], 0) + + with ( + patch.object(run_maestro, "_require_commands"), + patch.object(run_maestro, "_run_checked", side_effect=[failure, success]) as run_command, + patch.object(run_maestro.time, "sleep") as sleep, + redirect_stderr(io.StringIO()), + ): + run_maestro.build_jellyfin_image(config) + + self.assertEqual(run_command.call_count, 2) + sleep.assert_called_once_with(5) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/widgets/track_sheet_test.dart b/test/widgets/track_sheet_test.dart index 6a8f4506..8f993d6f 100644 --- a/test/widgets/track_sheet_test.dart +++ b/test/widgets/track_sheet_test.dart @@ -1,3 +1,5 @@ +import 'dart:ui' show Tristate; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/i18n/strings.g.dart'; @@ -120,6 +122,11 @@ void main() { expect(find.text('AAC · Stereo'), findsOneWidget); expect(find.text('Tamil'), findsOneWidget); expect(find.text('Dolby Digital Plus 5.1 with Atmos · E-AC3 · 5.1'), findsOneWidget); + + final englishTile = find.ancestor(of: find.text('English'), matching: find.byType(ListTile)); + final tamilTile = find.ancestor(of: find.text('Tamil'), matching: find.byType(ListTile)); + expect(tester.getSemantics(englishTile).getSemanticsData().flagsCollection.isSelected, Tristate.isTrue); + expect(tester.getSemantics(tamilTile).getSemanticsData().flagsCollection.isSelected, Tristate.isFalse); }); });