Skip to content

feat(directplay): Intelligently select best media combination for pla…#491

Merged
kingargyle merged 1 commit intodevelopfrom
dac_enhance_directplay_selection
Dec 1, 2025
Merged

feat(directplay): Intelligently select best media combination for pla…#491
kingargyle merged 1 commit intodevelopfrom
dac_enhance_directplay_selection

Conversation

@kingargyle
Copy link
Contributor

@kingargyle kingargyle commented Dec 1, 2025

…yback

Implements a robust media selection strategy to improve Direct Play reliability. The system now dynamically checks all available media streams against device capabilities and prioritizes the most compatible format.

This ensures that if a direct-playable combination exists, it is selected for playback, falling back to other streams or transcoding only when necessary.

Key changes include:

  • Media Sorting: MovieMediaContainer and EpisodeMediaContainer now sort media combinations by device compatibility before selecting one. This is driven by a new isDirectPlaySupported helper method that has been centralized in AbstractVideoContentInfo.

  • Dynamic Capability Checking:

    • AndroidHelper now uses ExoPlayer's AudioCapabilities to dynamically detect audio passthrough support, removing fragile, hardcoded device and codec checks.
    • MediaCodecInfoUtil has been refactored to cache codec support information, improving performance and removing redundant lookups.
  • Robust Combination Generation: Fixed a critical bug in MediaContainerAdaptor where only the last media stream was being considered. It now correctly generates all valid video/audio combinations for a given media item.

  • Testing:

    • Added new unit tests in MovieMediaContainerTest and EpisodeMediaContainerTest to verify the new media sorting logic.
    • Updated existing tests to provide the new AndroidHelper mock dependency, ensuring test stability.
    • Corrected a test-related crash by making production code in EpisodeMediaContainer more robust against unexpected data.

Summary by CodeRabbit

  • New Features

    • Added new content category views including unwatched items, recently added content, and up-next queue.
    • Enhanced organization of video and series lists.
  • Bug Fixes

    • Improved media playback selection to prioritize compatible formats.
    • Adjusted tunneling behavior for media delivery.

✏️ Tip: You can customize this high-level summary in your review settings.

…yback

Implements a robust media selection strategy to improve Direct Play
reliability. The system now dynamically checks all available media streams
against device capabilities and prioritizes the most compatible format.

This ensures that if a direct-playable combination exists, it is
selected for playback, falling back to other streams or transcoding
only when necessary.

Key changes include:

- **Media Sorting:** `MovieMediaContainer` and `EpisodeMediaContainer` now
  sort media combinations by device compatibility before selecting one.
  This is driven by a new `isDirectPlaySupported` helper method that has
  been centralized in `AbstractVideoContentInfo`.

- **Dynamic Capability Checking:**
  - `AndroidHelper` now uses ExoPlayer's `AudioCapabilities` to
    dynamically detect audio passthrough support, removing fragile,
    hardcoded device and codec checks.
  - `MediaCodecInfoUtil` has been refactored to cache codec support information,
    improving performance and removing redundant lookups.

- **Robust Combination Generation:** Fixed a critical bug in
  `MediaContainerAdaptor` where only the last media stream was being
  considered. It now correctly generates all valid video/audio
  combinations for a given media item.

- **Testing:**
  - Added new unit tests in `MovieMediaContainerTest` and
    `EpisodeMediaContainerTest` to verify the new media sorting logic.
  - Updated existing tests to provide the new `AndroidHelper` mock
    dependency, ensuring test stability.
  - Corrected a test-related crash by making production code in
    `EpisodeMediaContainer` more robust against unexpected data.
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Dec 1, 2025

Walkthrough

This change extends the MediaContainerAdaptor with new factory methods for creating video and series lists, adds direct-play capability detection to AbstractVideoContentInfo, disables tunneling in AndroidHelper, and updates episode/movie container logic to select media based on direct-play support compatibility.

Changes

Cohort / File(s) Summary
MediaContainerAdaptor expansion
emby-lib/src/main/kotlin/us/nineworlds/serenity/emby/adapters/MediaContainerAdaptor.kt
Added public factory methods: createUnwatched(), createRecentlyAddedCategory(), createOnDeck(), createAllCatagory(), createSeaonsList(), createVideoList(), and createPlayableLists(). Enhanced category creation to include "All" entry and conditional unwatched/recentlyAdded/onDeck entries. Implemented playable media list generation with per-video metadata (title, codecs, artwork, view counts, playback offset, duration).
Direct-play capability detection
serenity-app/src/main/java/us/nineworlds/serenity/core/model/impl/AbstractVideoContentInfo.kt
Added Toothpick dependency injection for AndroidHelper. Introduced isDirectPlaySupported(media: IMedia): Boolean method to check audio and video codec compatibility using MediaCodecInfoUtil and AndroidHelper.
Tunneling control
serenity-app/src/main/java/us/nineworlds/serenity/core/util/AndroidHelper.java
Changed enableTunneling() to always return false instead of returning the negation of isBeyondTV().
Container media selection
serenity-app/src/main/kotlin/us/nineworlds/serenity/core/model/impl/EpisodeMediaContainer.kt, MovieMediaContainer.kt
Updated media selection logic to sort media by isDirectPlaySupported() in descending order and use the first element, replacing direct firstOrNull() calls. Adjusted null/empty guarding in Episode container from null check to isNullOrEmpty().
Test infrastructure
serenity-app/src/test/kotlin/us/nineworlds/serenity/core/model/impl/AbstractVideoContentInfoTest.kt, EpisodeMediaContainerTest.kt, MovieMediaContainerTest.kt
Added mock AndroidHelper dependency and wired via Toothpick test module bindings. Enabled static mocking of MediaCodecInfoUtil. Extended test setup to simulate codec support checks, mime type resolution, and audio passthrough scenarios. Added test case validating direct-play media sorting.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

  • Areas requiring attention:
    • Logic change in AbstractVideoContentInfo.isDirectPlaySupported(): verify codec detection accuracy across audio/video combinations
    • Media selection sorting in EpisodeMediaContainer and MovieMediaContainer: confirm direct-play priority doesn't break fallback scenarios when no supported media exists
    • AndroidHelper.enableTunneling() behavioral change: verify this constant false return doesn't break downstream tunneling logic or error handling
    • Test mocks for MediaCodecInfoUtil and AndroidHelper: ensure mock coverage reflects real-world codec support scenarios

Possibly related PRs

Poem

🐰 A media list grows with each hop,
Direct play support, we'll never stop!
Factory methods bloom, containers know,
Which codec thrives and steals the show!
Sorting by strength, the best shall rise,
Tunneling falls—no surprise! 🎬

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: implementing intelligent media selection for direct play by choosing the best compatible combination.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch dac_enhance_directplay_selection

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@sonarqubecloud
Copy link

sonarqubecloud bot commented Dec 1, 2025

@codecov
Copy link

codecov bot commented Dec 1, 2025

Codecov Report

❌ Patch coverage is 72.22222% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 48.28%. Comparing base (8d403c9) to head (a885ee3).
⚠️ Report is 3 commits behind head on develop.

Files with missing lines Patch % Lines
...renity/core/model/impl/AbstractVideoContentInfo.kt 69.23% 0 Missing and 4 partials ⚠️
.../serenity/core/model/impl/EpisodeMediaContainer.kt 66.66% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@              Coverage Diff              @@
##             develop     #491      +/-   ##
=============================================
+ Coverage      47.87%   48.28%   +0.41%     
+ Complexity       445      442       -3     
=============================================
  Files             59       59              
  Lines           1878     1893      +15     
  Branches         252      255       +3     
=============================================
+ Hits             899      914      +15     
+ Misses           892      891       -1     
- Partials          87       88       +1     
Flag Coverage Δ
unittests 48.28% <72.22%> (+0.41%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (4)
serenity-app/src/main/java/us/nineworlds/serenity/core/util/AndroidHelper.java (1)

1-107: Consider converting to Kotlin.

Per coding guidelines, the primary language is Kotlin and Java files should be converted to Kotlin where feasible. This utility class is a good candidate for conversion.

Based on coding guidelines, **/*.java: Primary language is Kotlin; prefer converting Java to Kotlin where feasible.

emby-lib/src/main/kotlin/us/nineworlds/serenity/emby/adapters/MediaContainerAdaptor.kt (3)

33-55: Category construction logic OK; consider naming clean‑up

The category list construction (including conditional unwatched/recent/on‑deck entries) is clear and should behave as expected for series == false.

Two minor polish points you might consider (non‑blocking):

  • createAllCatagory / createSeaonsList contain typos in the identifiers, which can be confusing when reading or searching.
  • The local variable name allCategory is reused in helpers that aren’t actually “all” (Unwatched, Recently Added, OnDeck); a more generic name (categoryDir) would be clearer.

These are purely readability/API‑surface concerns and can be deferred.

Also applies to: 57-83


85-124: Series leaf/view counts – double‑check semantics

The series builder is generally solid (titles, ratings, artwork paths, and user data wiring all make sense). One thing to verify:

  • totalItemCount is computed as unplayedItemCount + playCount. If playCount represents “number of plays” rather than “number of played items”, this could overcount leaves and inflate leafCount.

If Emby’s playCount here is indeed “played items count” this is fine; otherwise it might be worth switching to a more direct count field from the API (if available).


126-128: Season list stub may lead to empty UI

createSeaonsList currently just returns an empty MediaContainer. If this is already wired into any UI flow, it will silently show no seasons.

If this is intentionally a placeholder, consider:

  • Adding a TODO comment, or
  • Implementing a minimal season mapping similar to createSeriesList/createVideoList.

Also, you may want to fix the spelling in the API name when you flesh this out (createSeasonsList).

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ed5c284 and a885ee3.

📒 Files selected for processing (8)
  • emby-lib/src/main/kotlin/us/nineworlds/serenity/emby/adapters/MediaContainerAdaptor.kt (1 hunks)
  • serenity-app/src/main/java/us/nineworlds/serenity/core/model/impl/AbstractVideoContentInfo.kt (2 hunks)
  • serenity-app/src/main/java/us/nineworlds/serenity/core/util/AndroidHelper.java (1 hunks)
  • serenity-app/src/main/kotlin/us/nineworlds/serenity/core/model/impl/EpisodeMediaContainer.kt (2 hunks)
  • serenity-app/src/main/kotlin/us/nineworlds/serenity/core/model/impl/MovieMediaContainer.kt (1 hunks)
  • serenity-app/src/test/kotlin/us/nineworlds/serenity/core/model/impl/AbstractVideoContentInfoTest.kt (1 hunks)
  • serenity-app/src/test/kotlin/us/nineworlds/serenity/core/model/impl/EpisodeMediaContainerTest.kt (4 hunks)
  • serenity-app/src/test/kotlin/us/nineworlds/serenity/core/model/impl/MovieMediaContainerTest.kt (4 hunks)
🧰 Additional context used
📓 Path-based instructions (4)
**/*.{kt,java}

📄 CodeRabbit inference engine (AGENTS.md)

Run formatting before commit: ./gradlew spotlessApply

Files:

  • serenity-app/src/main/java/us/nineworlds/serenity/core/model/impl/AbstractVideoContentInfo.kt
  • serenity-app/src/main/kotlin/us/nineworlds/serenity/core/model/impl/EpisodeMediaContainer.kt
  • serenity-app/src/main/java/us/nineworlds/serenity/core/util/AndroidHelper.java
  • serenity-app/src/test/kotlin/us/nineworlds/serenity/core/model/impl/AbstractVideoContentInfoTest.kt
  • serenity-app/src/test/kotlin/us/nineworlds/serenity/core/model/impl/MovieMediaContainerTest.kt
  • serenity-app/src/main/kotlin/us/nineworlds/serenity/core/model/impl/MovieMediaContainer.kt
  • serenity-app/src/test/kotlin/us/nineworlds/serenity/core/model/impl/EpisodeMediaContainerTest.kt
  • emby-lib/src/main/kotlin/us/nineworlds/serenity/emby/adapters/MediaContainerAdaptor.kt
**/*.kt

📄 CodeRabbit inference engine (AGENTS.md)

**/*.kt: Follow standard Kotlin naming conventions (camelCase for variables/functions, PascalCase for classes)
Use Toothpick for dependency injection with @Inject annotation and provider-based presenter injection for testability
Use Kotlin Coroutines for asynchronous work
Use Retrofit + OkHttp for network calls
Use Glide for image loading
Prefer immutable data structures where possible
Prefer immutability and read-only properties in data models

Files:

  • serenity-app/src/main/java/us/nineworlds/serenity/core/model/impl/AbstractVideoContentInfo.kt
  • serenity-app/src/main/kotlin/us/nineworlds/serenity/core/model/impl/EpisodeMediaContainer.kt
  • serenity-app/src/test/kotlin/us/nineworlds/serenity/core/model/impl/AbstractVideoContentInfoTest.kt
  • serenity-app/src/test/kotlin/us/nineworlds/serenity/core/model/impl/MovieMediaContainerTest.kt
  • serenity-app/src/main/kotlin/us/nineworlds/serenity/core/model/impl/MovieMediaContainer.kt
  • serenity-app/src/test/kotlin/us/nineworlds/serenity/core/model/impl/EpisodeMediaContainerTest.kt
  • emby-lib/src/main/kotlin/us/nineworlds/serenity/emby/adapters/MediaContainerAdaptor.kt
**/*.java

📄 CodeRabbit inference engine (AGENTS.md)

Primary language is Kotlin; prefer converting Java to Kotlin where feasible

Files:

  • serenity-app/src/main/java/us/nineworlds/serenity/core/util/AndroidHelper.java
**/*Test.kt

📄 CodeRabbit inference engine (AGENTS.md)

**/*Test.kt: Tests that require DI should inherit from InjectingTest
Unit tests required for all new features and bug fixes; use MockK for mocking with mockk(relaxed = true) for simple stubbing
Define mocks as properties in test classes and instantiate with mockk()
Use @before for setup and @after for teardown; always call clearAllMocks() in @after
Static mocks (mockkStatic/mockkObject) should be set up and torn down in @BeforeClass/@afterclass as required
Use Robolectric for Android-framework dependent tests
When converting tests to Kotlin, replace AssertJ assertions with AssertK equivalents
Test classes should extend InjectingTest and override setUp() calling super.setUp() first; implement installTestModules() to bind test modules

Files:

  • serenity-app/src/test/kotlin/us/nineworlds/serenity/core/model/impl/AbstractVideoContentInfoTest.kt
  • serenity-app/src/test/kotlin/us/nineworlds/serenity/core/model/impl/MovieMediaContainerTest.kt
  • serenity-app/src/test/kotlin/us/nineworlds/serenity/core/model/impl/EpisodeMediaContainerTest.kt
🧠 Learnings (9)
📚 Learning: 2025-11-28T20:52:23.735Z
Learnt from: CR
Repo: NineWorlds/serenity-android PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-28T20:52:23.735Z
Learning: Applies to **/*.kt : Use Toothpick for dependency injection with Inject annotation and provider-based presenter injection for testability

Applied to files:

  • serenity-app/src/main/java/us/nineworlds/serenity/core/model/impl/AbstractVideoContentInfo.kt
📚 Learning: 2025-11-28T20:52:23.735Z
Learnt from: CR
Repo: NineWorlds/serenity-android PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-28T20:52:23.735Z
Learning: Applies to **/*Test.kt : Unit tests required for all new features and bug fixes; use MockK for mocking with mockk(relaxed = true) for simple stubbing

Applied to files:

  • serenity-app/src/test/kotlin/us/nineworlds/serenity/core/model/impl/AbstractVideoContentInfoTest.kt
  • serenity-app/src/test/kotlin/us/nineworlds/serenity/core/model/impl/MovieMediaContainerTest.kt
  • serenity-app/src/test/kotlin/us/nineworlds/serenity/core/model/impl/EpisodeMediaContainerTest.kt
📚 Learning: 2025-11-28T20:52:23.735Z
Learnt from: CR
Repo: NineWorlds/serenity-android PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-28T20:52:23.735Z
Learning: Applies to **/*Test.kt : Define mocks as properties in test classes and instantiate with mockk()

Applied to files:

  • serenity-app/src/test/kotlin/us/nineworlds/serenity/core/model/impl/AbstractVideoContentInfoTest.kt
  • serenity-app/src/test/kotlin/us/nineworlds/serenity/core/model/impl/MovieMediaContainerTest.kt
  • serenity-app/src/test/kotlin/us/nineworlds/serenity/core/model/impl/EpisodeMediaContainerTest.kt
📚 Learning: 2025-11-28T20:52:23.735Z
Learnt from: CR
Repo: NineWorlds/serenity-android PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-28T20:52:23.735Z
Learning: Applies to **/*Test.kt : Test classes should extend InjectingTest and override setUp() calling super.setUp() first; implement installTestModules() to bind test modules

Applied to files:

  • serenity-app/src/test/kotlin/us/nineworlds/serenity/core/model/impl/AbstractVideoContentInfoTest.kt
  • serenity-app/src/test/kotlin/us/nineworlds/serenity/core/model/impl/MovieMediaContainerTest.kt
  • serenity-app/src/test/kotlin/us/nineworlds/serenity/core/model/impl/EpisodeMediaContainerTest.kt
📚 Learning: 2025-11-28T20:52:23.735Z
Learnt from: CR
Repo: NineWorlds/serenity-android PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-28T20:52:23.735Z
Learning: Applies to **/*Test.kt : Tests that require DI should inherit from InjectingTest

Applied to files:

  • serenity-app/src/test/kotlin/us/nineworlds/serenity/core/model/impl/AbstractVideoContentInfoTest.kt
  • serenity-app/src/test/kotlin/us/nineworlds/serenity/core/model/impl/MovieMediaContainerTest.kt
  • serenity-app/src/test/kotlin/us/nineworlds/serenity/core/model/impl/EpisodeMediaContainerTest.kt
📚 Learning: 2025-11-28T20:52:23.735Z
Learnt from: CR
Repo: NineWorlds/serenity-android PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-28T20:52:23.735Z
Learning: Applies to **/*Test.kt : Use Robolectric for Android-framework dependent tests

Applied to files:

  • serenity-app/src/test/kotlin/us/nineworlds/serenity/core/model/impl/AbstractVideoContentInfoTest.kt
  • serenity-app/src/test/kotlin/us/nineworlds/serenity/core/model/impl/MovieMediaContainerTest.kt
  • serenity-app/src/test/kotlin/us/nineworlds/serenity/core/model/impl/EpisodeMediaContainerTest.kt
📚 Learning: 2025-11-28T20:52:23.735Z
Learnt from: CR
Repo: NineWorlds/serenity-android PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-28T20:52:23.735Z
Learning: Applies to **/*Test.kt : When converting tests to Kotlin, replace AssertJ assertions with AssertK equivalents

Applied to files:

  • serenity-app/src/test/kotlin/us/nineworlds/serenity/core/model/impl/AbstractVideoContentInfoTest.kt
  • serenity-app/src/test/kotlin/us/nineworlds/serenity/core/model/impl/MovieMediaContainerTest.kt
  • serenity-app/src/test/kotlin/us/nineworlds/serenity/core/model/impl/EpisodeMediaContainerTest.kt
📚 Learning: 2025-11-28T20:52:23.735Z
Learnt from: CR
Repo: NineWorlds/serenity-android PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-28T20:52:23.735Z
Learning: Applies to **/*Test.kt : Static mocks (mockkStatic/mockkObject) should be set up and torn down in BeforeClass/AfterClass as required

Applied to files:

  • serenity-app/src/test/kotlin/us/nineworlds/serenity/core/model/impl/AbstractVideoContentInfoTest.kt
  • serenity-app/src/test/kotlin/us/nineworlds/serenity/core/model/impl/MovieMediaContainerTest.kt
  • serenity-app/src/test/kotlin/us/nineworlds/serenity/core/model/impl/EpisodeMediaContainerTest.kt
📚 Learning: 2025-11-28T20:52:23.735Z
Learnt from: CR
Repo: NineWorlds/serenity-android PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-28T20:52:23.735Z
Learning: Applies to **/*Test.kt : Use Before for setup and After for teardown; always call clearAllMocks() in After

Applied to files:

  • serenity-app/src/test/kotlin/us/nineworlds/serenity/core/model/impl/AbstractVideoContentInfoTest.kt
  • serenity-app/src/test/kotlin/us/nineworlds/serenity/core/model/impl/MovieMediaContainerTest.kt
  • serenity-app/src/test/kotlin/us/nineworlds/serenity/core/model/impl/EpisodeMediaContainerTest.kt
🧬 Code graph analysis (3)
serenity-app/src/test/kotlin/us/nineworlds/serenity/core/model/impl/AbstractVideoContentInfoTest.kt (3)
serenity-app/src/test/kotlin/us/nineworlds/serenity/core/model/impl/EpisodeMediaContainerTest.kt (1)
  • bind (204-210)
serenity-app/src/test/kotlin/us/nineworlds/serenity/core/model/impl/MovieMediaContainerTest.kt (1)
  • bind (169-175)
serenity-app/src/test/kotlin/us/nineworlds/serenity/core/model/impl/AbstractMediaContainerTest.kt (1)
  • bind (62-67)
serenity-app/src/main/kotlin/us/nineworlds/serenity/core/model/impl/MovieMediaContainer.kt (1)
serenity-app/src/main/java/us/nineworlds/serenity/core/model/impl/AbstractVideoContentInfo.kt (1)
  • isDirectPlaySupported (181-193)
serenity-app/src/test/kotlin/us/nineworlds/serenity/core/model/impl/EpisodeMediaContainerTest.kt (2)
serenity-app/src/test/kotlin/us/nineworlds/serenity/core/model/impl/AbstractVideoContentInfoTest.kt (1)
  • bind (169-175)
serenity-app/src/test/kotlin/us/nineworlds/serenity/core/model/impl/MovieMediaContainerTest.kt (1)
  • bind (169-175)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (16)
serenity-app/src/main/kotlin/us/nineworlds/serenity/core/model/impl/MovieMediaContainer.kt (1)

48-49: LGTM! Smart media selection strategy implemented correctly.

The sorting logic properly prioritizes direct-playable media combinations by checking device compatibility before selection. The use of sortedByDescending with the isDirectPlaySupported predicate ensures that the most compatible media is chosen first, with graceful fallback to other media if no direct-playable option exists.

The implementation follows Kotlin best practices:

  • Maintains immutability by creating a new sorted list
  • Uses safe call operators for null safety
  • Leverages stable sorting to preserve original order when direct-play support is equal
serenity-app/src/main/java/us/nineworlds/serenity/core/util/AndroidHelper.java (1)

68-70: Verify impact of disabling tunneling universally.

The method now unconditionally returns false, which disables audio/video tunneling for all devices. Ensure this change aligns with the expected Direct Play behavior and doesn't negatively impact playback performance on devices that previously benefited from tunneling.

serenity-app/src/main/java/us/nineworlds/serenity/core/model/impl/AbstractVideoContentInfo.kt (2)

20-26: DI initialization follows Toothpick pattern correctly.

The dependency injection setup properly uses the @Inject annotation and initializes androidHelper via Toothpick in the init block.

Based on learnings, Toothpick is used for dependency injection with the @Inject annotation.


181-193: No issues found. The media.audioCodec and media.videoCodec properties store plain codec names (e.g., "aac", "h264") without MIME type prefixes. The findCorrectAudioMimeType() and findCorrectVideoMimeType() methods correctly handle the prepended "audio/" and "video/" prefixes by using substringAfter() to safely extract the codec portion before applying their transformations. The implementation is safe and will not produce malformed MIME types.

Likely an incorrect or invalid review comment.

serenity-app/src/test/kotlin/us/nineworlds/serenity/core/model/impl/AbstractVideoContentInfoTest.kt (2)

29-64: Test structure follows coding guidelines correctly.

The test properly:

  • Extends InjectingTest
  • Uses mockk(relaxed = true) for mocks
  • Calls super.setUp() first
  • Uses clearAllMocks() in @After
  • Manages static mocks in companion object with @BeforeClass/@AfterClass

Based on coding guidelines, tests should extend InjectingTest and follow the established patterns for setup/teardown.


169-175: AndroidHelper binding aligns with DI pattern.

The TestModule correctly binds the mock AndroidHelper instance, enabling dependency injection for tests.

serenity-app/src/test/kotlin/us/nineworlds/serenity/core/model/impl/EpisodeMediaContainerTest.kt (3)

85-89: Comprehensive codec support mocking.

The test properly mocks both MediaCodecInfoUtil static methods and AndroidHelper instance methods to simulate codec support checks for direct play scenarios.


166-202: Direct play sorting test validates new behavior.

The test effectively verifies that media with supported codecs (ac3/h264) is selected over unsupported media (flac/hevc) through the sorting mechanism.


208-208: AndroidHelper DI binding enables test mocking.

Binding AndroidHelper in the TestModule allows tests to control audio passthrough behavior.

serenity-app/src/main/kotlin/us/nineworlds/serenity/core/model/impl/EpisodeMediaContainer.kt (2)

17-17: More robust null/empty checking.

Using isNullOrEmpty() is more idiomatic than a simple null check and prevents potential issues with empty strings.


63-65: Sorting prioritizes direct-play-compatible media.

The implementation correctly sorts media by direct play support (descending) and selects the first item. This ensures that if any media is direct-play-compatible, it will be chosen.

serenity-app/src/test/kotlin/us/nineworlds/serenity/core/model/impl/MovieMediaContainerTest.kt (3)

78-80: Codec support mocking enables direct play testing.

The test properly sets up mocks for MediaCodecInfoUtil and AndroidHelper to validate codec support checks during media selection.


131-167: Direct play sorting test validates selection logic.

The test effectively verifies that the media selection prioritizes supported codecs (ac3/h264) over unsupported ones (flac/hevc), matching the behavior in EpisodeMediaContainerTest.


173-173: AndroidHelper DI binding consistent across tests.

The TestModule binding matches the pattern used in other test files, enabling consistent mocking of Android capabilities.

emby-lib/src/main/kotlin/us/nineworlds/serenity/emby/adapters/MediaContainerAdaptor.kt (2)

14-16: Time tick conversion helper looks good

Using a single TICKS_PER_MILLISECOND constant and convertTicksToMilliseconds keeps the tick math centralized and avoids magic numbers. No issues here.

Also applies to: 221-221


18-31: Main menu mapping is straightforward and correct

The createMainMenu implementation is a simple projection from Item to Directory with no obvious edge‑case pitfalls. This looks fine as is.

Comment on lines +130 to 185
fun createVideoList(videos: List<Item>): IMediaContainer {
val mediaContainer = MediaContainer()
val serenityVideos = ArrayList<Video>()
mediaContainer.size = videos.size

val items = videos.filter { item -> item.type != "Folder" }
.filterNot { item -> item.name == "TBA" }

for (item in items) {
val video = Video()

val sortEpisode = item.episodeNumber?.toInt() ?: 0

video.type = item.type
video.titleSort = sortEpisode.toString().padStart(3, '0')
video.title = item.name
video.key = item.id
video.parentKey = item.parentId
video.contentRating = item.officialRating
video.summary = item.oveview
video.rating = item.communityRating ?: 0.00
video.season = item.parentIndexNumber
video.seriesName = item.seriesName

if (item.type != null && item.type == "Episode") {
video.backgroundImageKey = "/emby/Items/${item.parentId}/Images/Backdrop"
video.parentThumbNailImageKey = "/emby/Items/${item.parentId}/Images/Primary"
} else {
video.backgroundImageKey = "/emby/Items/${item.id}/Images/Backdrop"
}
video.thumbNailImageKey = "/emby/Items/${item.id}/Images/Primary"
video.viewCount = item.userData?.playCount?.toInt() ?: 0
val offset = convertTicksToMilliseconds(item.userData?.playbackPositionTicks ?: 0)
video.viewOffset = offset
video.episode = item.episodeNumber

val container = if (item.container != null && item.container.contains(",")) {
item.container.substringBefore(",")
} else {
item.container
}
video.directPlayUrl =
"emby/Videos/${item.mediaSources?.get(0)?.id ?: item.id}/stream.$container?static=true"

if (item.runTimeTicks != null) {
val milliseconds = convertTicksToMilliseconds(item.runTimeTicks)
video.duration = milliseconds
}

video.medias = createPlayableLists(item)

serenityVideos.add(video)
}
mediaContainer.videos = serenityVideos.sortedBy { item -> item.titleSort }.toList()
return mediaContainer
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, verify the file exists and examine the code at the specified lines
find . -type f -name "MediaContainerAdaptor.kt" -o -name "*MediaContainer*" | head -20

Repository: NineWorlds/serenity-android

Length of output: 1961


🏁 Script executed:

# List files in the emby-lib directory to understand the structure
git ls-files emby-lib/src/main/kotlin/us/nineworlds/serenity/emby/ | head -30

Repository: NineWorlds/serenity-android

Length of output: 1116


🏁 Script executed:

# Read the actual MediaContainerAdaptor.kt file to verify the code
cat -n emby-lib/src/main/kotlin/us/nineworlds/serenity/emby/adapters/MediaContainerAdaptor.kt

Repository: NineWorlds/serenity-android

Length of output: 9398


🏁 Script executed:

# Find and examine the Item model
find . -name "itemsmodel.kt" -o -name "*Item.kt" | grep -E "(emby|model)" | head -10

Repository: NineWorlds/serenity-android

Length of output: 242


🏁 Script executed:

# Read the Item model definition
cat -n emby-lib/src/main/kotlin/us/nineworlds/serenity/emby/server/model/itemsmodel.kt

Repository: NineWorlds/serenity-android

Length of output: 5302


🏁 Script executed:

# Check if there are any tests for MediaContainerAdaptor
find . -name "*MediaContainerAdaptor*" -type f

Repository: NineWorlds/serenity-android

Length of output: 264


🏁 Script executed:

# Check for tests of createVideoList
find . -type f -name "*.kt" | xargs grep -l "createVideoList" 2>/dev/null

Repository: NineWorlds/serenity-android

Length of output: 442


🏁 Script executed:

# Check the JellyfinMediaContainerAdaptor for comparison
cat -n jellyfin-lib/src/main/kotlin/us/nineworlds/serenity/jellyfin/adapters/JellyfinMediaContainerAdaptor.kt | head -220

Repository: NineWorlds/serenity-android

Length of output: 8083


Fix three robustness issues in video list creation

The code has three correctness concerns that should be addressed:

  1. IndexOutOfBoundsException on empty mediaSources
    item.mediaSources?.get(0)?.id throws if mediaSources is a non-null but empty list. Use firstOrNull() instead.

  2. mediaContainer.size mismatch
    Line 133 sets mediaContainer.size = videos.size before filtering. Lines 135–136 then filter out folders and "TBA" items, causing the reported size to exceed the actual video count. Set size to items.size after filtering.

  3. Null container produces stream.null
    When item.container is null, the URL becomes stream.null. Use a safe approach to append the extension only when container exists:

    val container = item.container?.let { raw ->
        if (raw.contains(",")) raw.substringBefore(",") else raw
    }
    val mediaSourceId = item.mediaSources?.firstOrNull()?.id ?: item.id
    val extension = container?.let { ".$it" } ?: ""
    video.directPlayUrl = "emby/Videos/$mediaSourceId/stream$extension?static=true"
🤖 Prompt for AI Agents
In
emby-lib/src/main/kotlin/us/nineworlds/serenity/emby/adapters/MediaContainerAdaptor.kt
around lines 130 to 185, address three robustness issues: (1) replace
mediaSources?.get(0)?.id with a safe lookup using firstOrNull() so an empty
non-null list doesn't throw IndexOutOfBoundsException; (2) set
mediaContainer.size after filtering (use items.size) instead of using the
original videos.size so the reported size matches the actual list; (3) avoid
producing "stream.null" by handling a null container safely — extract the
container extension only when non-null (and handle comma-separated values),
build the mediaSourceId via firstOrNull() fallback to item.id, and append the
extension only if present when composing directPlayUrl.

Comment on lines +187 to 219
fun createPlayableLists(item: Item): List<Media> {
val medias = mutableListOf<Media>()
if (item.mediaStreams.isNullOrEmpty()) {
return medias
}

val videoStreams = item.mediaStreams.filter { it.type == "Video" }
val audioStreams = item.mediaStreams.filter { it.type == "Audio" }

// If there's no video or no audio, no valid combination can be made.
if (videoStreams.isEmpty() || audioStreams.isEmpty()) {
return medias
}

videoStreams.forEach { videoStream ->
audioStreams.forEach { audioStream ->
val media = Media() // Create a new object for each combination
media.container = item.container

// From video stream
media.aspectRatio = videoStream.aspectRatio
media.videoCodec = videoStream.codec

// From audio stream
media.audioCodec = audioStream.codec
media.audioChannels = audioStream.channels

medias.add(media)
}
}
video.medias = medias.toList()
}

serenityVideos.add(video)
return medias
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

fd "MediaContainerAdaptor.kt" --type f

Repository: NineWorlds/serenity-android

Length of output: 260


🏁 Script executed:

find . -name "MediaContainerAdaptor.kt" -type f -exec wc -l {} \;

Repository: NineWorlds/serenity-android

Length of output: 163


🏁 Script executed:

rg "class MediaContainerAdaptor" -A 5

Repository: NineWorlds/serenity-android

Length of output: 707


🏁 Script executed:

find . -path "*emby*" -name "*Adaptor*.kt" -type f

Repository: NineWorlds/serenity-android

Length of output: 159


🏁 Script executed:

cat -n emby-lib/src/main/kotlin/us/nineworlds/serenity/emby/adapters/MediaContainerAdaptor.kt

Repository: NineWorlds/serenity-android

Length of output: 9398


🏁 Script executed:

find . -name "*.kt" -type f -exec grep -l "class Media" {} \;

Repository: NineWorlds/serenity-android

Length of output: 496


🏁 Script executed:

rg "Media\(" -A 3 | head -50

Repository: NineWorlds/serenity-android

Length of output: 1165


🏁 Script executed:

rg "\.container" --type kt -B 2 -A 2 | head -100

Repository: NineWorlds/serenity-android

Length of output: 97


🏁 Script executed:

find . -path "*emby-lib*" -name "*.kt" -type f | grep -i media

Repository: NineWorlds/serenity-android

Length of output: 159


🏁 Script executed:

rg "\.container\s*[=:]|container.*=" --type-list | head -5

Repository: NineWorlds/serenity-android

Length of output: 460


🏁 Script executed:

rg "\.container" -B 2 -A 2 | grep -E "(container|direct|play|capability)" | head -60

Repository: NineWorlds/serenity-android

Length of output: 5609


🏁 Script executed:

rg "isDirectPlaySupportedForContainer" -B 2 -A 10

Repository: NineWorlds/serenity-android

Length of output: 3664


Apply container normalization in createPlayableLists to match createVideoList handling

The code in createVideoList normalizes comma-separated container strings before use (lines 166-170), but createPlayableLists assigns the raw item.container directly to media.container (line 204). This inconsistency should be corrected to ensure predictable container values downstream.

Apply this normalization in createPlayableLists:

media.container = item.container?.let { raw ->
    if (raw.contains(",")) raw.substringBefore(",") else raw
}

This keeps the container handling consistent across both methods and prevents potential issues if future capability checks or direct-play logic depend on normalized container values.

🤖 Prompt for AI Agents
In
emby-lib/src/main/kotlin/us/nineworlds/serenity/emby/adapters/MediaContainerAdaptor.kt
around lines 187 to 219, createPlayableLists assigns item.container directly to
media.container, causing inconsistent container values vs createVideoList; fix
by normalizing item.container the same way as createVideoList: if item.container
is null leave media.container null, otherwise if it contains a comma set
media.container to the substring before the first comma, else set it to the
original string (use safe null handling to avoid NPEs).

override fun setUp() {
super.setUp()
every { mockSerenityClient.baseURL() } returns "http://localhost:8096/"
mockkObject(MediaCodecInfoUtil)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add cleanup for mockkObject to prevent test pollution.

mockkObject(MediaCodecInfoUtil) is initialized in @Before but there's no corresponding unmockkObject in @After. This can cause test pollution if MediaCodecInfoUtil is used by other tests.

Based on coding guidelines, static mocks should be properly torn down.

Apply this diff:

 @After
 fun tearDown() {
+  unmockkObject(MediaCodecInfoUtil)
   clearAllMocks()
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
mockkObject(MediaCodecInfoUtil)
@After
fun tearDown() {
unmockkObject(MediaCodecInfoUtil)
clearAllMocks()
}
🤖 Prompt for AI Agents
In
serenity-app/src/test/kotlin/us/nineworlds/serenity/core/model/impl/EpisodeMediaContainerTest.kt
around line 43, the test uses mockkObject(MediaCodecInfoUtil) in setup but lacks
teardown, causing static mock leakage; add an @After method that calls
unmockkObject(MediaCodecInfoUtil) (or unmockkAll() if appropriate) to restore
original behavior and prevent test pollution, ensuring the static mock is
properly removed after each test.

override fun setUp() {
super.setUp()
every { mockSerenityClient.baseURL() } returns "http://localhost:8096/"
mockkObject(MediaCodecInfoUtil)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add cleanup for mockkObject to prevent test pollution.

mockkObject(MediaCodecInfoUtil) is initialized in @Before but there's no corresponding unmockkObject in @After. This can cause test pollution if MediaCodecInfoUtil is used by other tests.

Based on coding guidelines, static mocks should be properly torn down.

Apply this diff:

 @After
 fun tearDown() {
+  unmockkObject(MediaCodecInfoUtil)
   clearAllMocks()
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
mockkObject(MediaCodecInfoUtil)
@After
fun tearDown() {
unmockkObject(MediaCodecInfoUtil)
clearAllMocks()
}
🤖 Prompt for AI Agents
In
serenity-app/src/test/kotlin/us/nineworlds/serenity/core/model/impl/MovieMediaContainerTest.kt
around line 42, the test sets up a static mock with
mockkObject(MediaCodecInfoUtil) in @Before but never tears it down; add an
@After teardown that calls unmockkObject(MediaCodecInfoUtil) (or unmockkAll() if
you prefer clearing all static mocks) to ensure the static mock is removed after
each test and prevent cross-test pollution.

@kingargyle kingargyle merged commit 0917674 into develop Dec 1, 2025
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant