-
Notifications
You must be signed in to change notification settings - Fork 65
feat(directplay): Intelligently select best media combination for pla… #491
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,192 +11,212 @@ import us.nineworlds.serenity.emby.server.model.NameGuidPair | |
|
|
||
| class MediaContainerAdaptor { | ||
|
|
||
| companion object { | ||
| const val TICKS_PER_MILLISECOND: Long = 10000 | ||
| } | ||
|
|
||
| fun createMainMenu(items: List<Item>): IMediaContainer { | ||
| val mediaContainer = MediaContainer() | ||
| val directories = ArrayList<Directory>() | ||
| for (item in items) { | ||
| val entry = Directory() | ||
| entry.title = item.name | ||
| entry.type = item.collectionType | ||
| entry.key = item.id | ||
| directories.add(entry) | ||
| companion object { | ||
| const val TICKS_PER_MILLISECOND: Long = 10000 | ||
| } | ||
|
|
||
| mediaContainer.directories = directories.toList() | ||
| return mediaContainer | ||
| } | ||
| fun createMainMenu(items: List<Item>): IMediaContainer { | ||
| val mediaContainer = MediaContainer() | ||
| val directories = ArrayList<Directory>() | ||
| for (item in items) { | ||
| val entry = Directory() | ||
| entry.title = item.name | ||
| entry.type = item.collectionType | ||
| entry.key = item.id | ||
| directories.add(entry) | ||
| } | ||
|
|
||
| mediaContainer.directories = directories.toList() | ||
| return mediaContainer | ||
| } | ||
|
|
||
| fun createCategory(genres: List<NameGuidPair>, series: Boolean? = false): IMediaContainer { | ||
| val mediaContainer = MediaContainer() | ||
| val directories = ArrayList<Directory>() | ||
| fun createCategory(genres: List<NameGuidPair>, series: Boolean? = false): IMediaContainer { | ||
| val mediaContainer = MediaContainer() | ||
| val directories = ArrayList<Directory>() | ||
|
|
||
| directories.add(createAllCatagory()) | ||
| if (series == false) { | ||
| directories.add(createUnwatched()) | ||
| directories.add(createRecentlyAddedCategory()) | ||
| directories.add(createOnDeck()) | ||
| } | ||
|
|
||
| directories.add(createAllCatagory()) | ||
| if (series == false) { | ||
| directories.add(createUnwatched()) | ||
| directories.add(createRecentlyAddedCategory()) | ||
| directories.add(createOnDeck()) | ||
| for (genre in genres) { | ||
| val entry = Directory() | ||
| entry.title = genre.name | ||
| entry.key = genre.name | ||
| entry.secondary = 0 | ||
| directories.add(entry) | ||
| } | ||
|
|
||
| mediaContainer.directories = directories.toList() | ||
|
|
||
| return mediaContainer | ||
| } | ||
|
|
||
| for (genre in genres) { | ||
| val entry = Directory() | ||
| entry.title = genre.name | ||
| entry.key = genre.name | ||
| entry.secondary = 0 | ||
| directories.add(entry) | ||
| fun createAllCatagory(): Directory { | ||
| val allCategory = Directory() | ||
| allCategory.title = "All" | ||
| allCategory.key = "all" | ||
| return allCategory | ||
| } | ||
|
|
||
| mediaContainer.directories = directories.toList() | ||
|
|
||
| return mediaContainer | ||
| } | ||
|
|
||
| fun createAllCatagory(): Directory { | ||
| val allCategory = Directory() | ||
| allCategory.title = "All" | ||
| allCategory.key = "all" | ||
| return allCategory | ||
| } | ||
|
|
||
| fun createUnwatched(): Directory { | ||
| val allCategory = Directory() | ||
| allCategory.title = "Unwatched" | ||
| allCategory.key = "unwatched" | ||
| return allCategory | ||
| } | ||
|
|
||
| fun createRecentlyAddedCategory(): Directory { | ||
| val allCategory = Directory() | ||
| allCategory.title = "Recently Added" | ||
| allCategory.key = "recentlyAdded" | ||
| return allCategory | ||
| } | ||
|
|
||
| fun createOnDeck(): Directory { | ||
| val allCategory = Directory() | ||
| allCategory.title = "OnDeck" | ||
| allCategory.key = "ondeck" | ||
| return allCategory | ||
| } | ||
|
|
||
| fun createSeriesList(series: List<Item>): IMediaContainer { | ||
| val mediaContainer = MediaContainer() | ||
| val seriesVideos = ArrayList<IDirectory>() | ||
|
|
||
| for (item in series) { | ||
| val seriesEntry = Directory() | ||
|
|
||
| seriesEntry.title = item.name | ||
| seriesEntry.summary = item.oveview | ||
| seriesEntry.key = item.id | ||
| seriesEntry.contentRating = item.officialRating | ||
| seriesEntry.rating = (item.communityRating ?: 0.00).toString() | ||
|
|
||
| var totalItemCount = 0L | ||
| var viewdItemsCount = 0L | ||
| if (item.userData != null) { | ||
| if (item.userData.unplayedItemCount != null) { | ||
| totalItemCount += item.userData.unplayedItemCount | ||
| } | ||
| fun createUnwatched(): Directory { | ||
| val allCategory = Directory() | ||
| allCategory.title = "Unwatched" | ||
| allCategory.key = "unwatched" | ||
| return allCategory | ||
| } | ||
|
|
||
| fun createRecentlyAddedCategory(): Directory { | ||
| val allCategory = Directory() | ||
| allCategory.title = "Recently Added" | ||
| allCategory.key = "recentlyAdded" | ||
| return allCategory | ||
| } | ||
|
|
||
| fun createOnDeck(): Directory { | ||
| val allCategory = Directory() | ||
| allCategory.title = "OnDeck" | ||
| allCategory.key = "ondeck" | ||
| return allCategory | ||
| } | ||
|
|
||
| fun createSeriesList(series: List<Item>): IMediaContainer { | ||
| val mediaContainer = MediaContainer() | ||
| val seriesVideos = ArrayList<IDirectory>() | ||
|
|
||
| for (item in series) { | ||
| val seriesEntry = Directory() | ||
|
|
||
| seriesEntry.title = item.name | ||
| seriesEntry.summary = item.oveview | ||
| seriesEntry.key = item.id | ||
| seriesEntry.contentRating = item.officialRating | ||
| seriesEntry.rating = (item.communityRating ?: 0.00).toString() | ||
|
|
||
| if (item.userData.playCount != null) { | ||
| viewdItemsCount = item.userData.playCount | ||
| totalItemCount += viewdItemsCount | ||
| var totalItemCount = 0L | ||
| var viewdItemsCount = 0L | ||
| if (item.userData != null) { | ||
| if (item.userData.unplayedItemCount != null) { | ||
| totalItemCount += item.userData.unplayedItemCount | ||
| } | ||
|
|
||
| if (item.userData.playCount != null) { | ||
| viewdItemsCount = item.userData.playCount | ||
| totalItemCount += viewdItemsCount | ||
| } | ||
| } | ||
|
|
||
| seriesEntry.leafCount = totalItemCount.toString() | ||
| seriesEntry.viewedLeafCount = viewdItemsCount.toString() | ||
|
|
||
| seriesEntry.art = "/emby/Items/${item.id}/Images/Thumb" | ||
| seriesEntry.thumb = "/emby/Items/${item.id}/Images/Primary" | ||
| seriesEntry.banner = "/emby/Items/${item.id}/Images/Banner" | ||
|
|
||
| seriesVideos.add(seriesEntry) | ||
| } | ||
| } | ||
| mediaContainer.directories = seriesVideos | ||
| mediaContainer.size = series.size | ||
|
|
||
| seriesEntry.leafCount = totalItemCount.toString() | ||
| seriesEntry.viewedLeafCount = viewdItemsCount.toString() | ||
| return mediaContainer | ||
| } | ||
|
|
||
| seriesEntry.art = "/emby/Items/${item.id}/Images/Thumb" | ||
| seriesEntry.thumb = "/emby/Items/${item.id}/Images/Primary" | ||
| seriesEntry.banner = "/emby/Items/${item.id}/Images/Banner" | ||
| fun createSeaonsList(seaons: List<Item>): IMediaContainer { | ||
| return MediaContainer() | ||
| } | ||
|
|
||
| seriesVideos.add(seriesEntry) | ||
| 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 | ||
| } | ||
| mediaContainer.directories = seriesVideos | ||
| mediaContainer.size = series.size | ||
|
|
||
| return mediaContainer | ||
| } | ||
|
|
||
| fun createSeaonsList(seaons: List<Item>): IMediaContainer { | ||
| return MediaContainer() | ||
| } | ||
|
|
||
| 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 | ||
| } | ||
|
|
||
| if (item.mediaStreams != null) { | ||
| val medias = ArrayList<Media>() | ||
| val media = Media() | ||
| for (mediaStream in item.mediaStreams) { | ||
| media.container = item.container | ||
| if (mediaStream.type == "Video") { | ||
| media.aspectRatio = mediaStream.aspectRatio | ||
| media.videoCodec = mediaStream.codec | ||
| } else if (mediaStream.type == "Audio") { | ||
| media.audioCodec = mediaStream.codec | ||
| media.audioChannels = mediaStream.channels | ||
| } | ||
| medias.add(media) | ||
|
|
||
| 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 | ||
| } | ||
|
Comment on lines
+187
to
219
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: fd "MediaContainerAdaptor.kt" --type fRepository: 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 5Repository: NineWorlds/serenity-android Length of output: 707 🏁 Script executed: find . -path "*emby*" -name "*Adaptor*.kt" -type fRepository: NineWorlds/serenity-android Length of output: 159 🏁 Script executed: cat -n emby-lib/src/main/kotlin/us/nineworlds/serenity/emby/adapters/MediaContainerAdaptor.ktRepository: 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 -50Repository: NineWorlds/serenity-android Length of output: 1165 🏁 Script executed: rg "\.container" --type kt -B 2 -A 2 | head -100Repository: NineWorlds/serenity-android Length of output: 97 🏁 Script executed: find . -path "*emby-lib*" -name "*.kt" -type f | grep -i mediaRepository: NineWorlds/serenity-android Length of output: 159 🏁 Script executed: rg "\.container\s*[=:]|container.*=" --type-list | head -5Repository: NineWorlds/serenity-android Length of output: 460 🏁 Script executed: rg "\.container" -B 2 -A 2 | grep -E "(container|direct|play|capability)" | head -60Repository: NineWorlds/serenity-android Length of output: 5609 🏁 Script executed: rg "isDirectPlaySupportedForContainer" -B 2 -A 10Repository: NineWorlds/serenity-android Length of output: 3664 Apply container normalization in The code in Apply this normalization in 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 |
||
| mediaContainer.videos = serenityVideos.sortedBy { item -> item.titleSort } .toList() | ||
| return mediaContainer | ||
| } | ||
|
|
||
| fun convertTicksToMilliseconds(ticks: Long): Long = ticks.div(TICKS_PER_MILLISECOND) | ||
| fun convertTicksToMilliseconds(ticks: Long): Long = ticks.div(TICKS_PER_MILLISECOND) | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: NineWorlds/serenity-android
Length of output: 1961
🏁 Script executed:
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.ktRepository: NineWorlds/serenity-android
Length of output: 9398
🏁 Script executed:
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.ktRepository: NineWorlds/serenity-android
Length of output: 5302
🏁 Script executed:
Repository: NineWorlds/serenity-android
Length of output: 264
🏁 Script executed:
Repository: NineWorlds/serenity-android
Length of output: 442
🏁 Script executed:
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:
IndexOutOfBoundsExceptionon emptymediaSourcesitem.mediaSources?.get(0)?.idthrows ifmediaSourcesis a non-null but empty list. UsefirstOrNull()instead.mediaContainer.sizemismatchLine 133 sets
mediaContainer.size = videos.sizebefore filtering. Lines 135–136 then filter out folders and "TBA" items, causing the reported size to exceed the actual video count. Set size toitems.sizeafter filtering.Null
containerproducesstream.nullWhen
item.containeris null, the URL becomesstream.null. Use a safe approach to append the extension only when container exists:🤖 Prompt for AI Agents