Add route metadata to media item ids

This commit is contained in:
Niels van Velzen
2026-05-29 21:53:23 +02:00
committed by Niels van Velzen
parent 08e455959c
commit 56cd71d29e
3 changed files with 99 additions and 70 deletions
@@ -0,0 +1,20 @@
@file:UseSerializers(UUIDSerializer::class)
package org.jellyfin.mobile.sessionbrowser
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.serializer.UUIDSerializer
import java.util.UUID
@Serializable
sealed interface LibraryMediaId {
@Serializable
@SerialName("item")
data class Item(val itemId: UUID, val route: LibraryRoute) : LibraryMediaId
@Serializable
@SerialName("route")
data class Route(val route: LibraryRoute) : LibraryMediaId
}
@@ -2,7 +2,6 @@ package org.jellyfin.mobile.sessionbrowser
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.MediaLibraryService
import androidx.media3.session.MediaSession
@@ -15,41 +14,40 @@ class LibraryService : MediaLibraryService() {
private val apiClientController: ApiClientController by inject()
private val apiClient: ApiClient by inject()
private var mediaLibrarySession: MediaLibrarySession? = null
private var callback: MediaLibrarySession.Callback? = null
private val playerAudioAttributes = AudioAttributes.Builder()
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
.setUsage(C.USAGE_MEDIA)
.build()
private val exoPlayer: Player by lazy {
private val exoPlayer: ExoPlayer by lazy {
ExoPlayer.Builder(this).build().apply {
setAudioAttributes(playerAudioAttributes, true)
setHandleAudioBecomingNoisy(true)
}
}
private val mediaLibrarySession: MediaLibrarySession by lazy {
MediaLibrarySession.Builder(this, exoPlayer, callback)
.build()
}
private val callback: MediaLibrarySession.Callback by lazy {
SessionBrowserCallback(this, apiClient)
}
override fun onGetSession(
controllerInfo: MediaSession.ControllerInfo,
): MediaLibrarySession? = mediaLibrarySession
): MediaLibrarySession = mediaLibrarySession
override fun onCreate() {
super.onCreate()
runBlocking { apiClientController.loadSavedServerUser() }
if (callback == null) callback = SessionBrowserCallback(this, apiClient)
mediaLibrarySession = MediaLibrarySession.Builder(this, exoPlayer, callback!!)
.build()
}
override fun onDestroy() {
mediaLibrarySession?.run {
player.release()
release()
mediaLibrarySession = null
}
exoPlayer.release()
mediaLibrarySession.release()
super.onDestroy()
}
@@ -42,8 +42,6 @@ import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.universalAudioApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.MediaStreamProtocol
import org.jellyfin.sdk.model.serializer.toUUID
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
@Suppress("InjectDispatcher")
@@ -79,6 +77,7 @@ class SessionBrowserCallback(
private val LibraryRoute.page get() = pages.firstOrNull { page -> page.route == this::class }
private fun LibraryPageElement.Item.toMediaItem(
route: LibraryRoute,
groupTitle: String? = null,
): MediaItem = MediaItem.Builder().apply {
val extras = bundleOf()
@@ -95,9 +94,9 @@ class SessionBrowserCallback(
}
extras.putInt(MediaConstants.EXTRAS_KEY_CONTENT_STYLE_BROWSABLE, contentStyle)
extras.putInt(MediaConstants.EXTRAS_KEY_CONTENT_STYLE_PLAYABLE, contentStyle)
setMediaId(Json.encodeToString(action.route))
setMediaId(Json.encodeToString<LibraryMediaId>(LibraryMediaId.Route(action.route)))
} else if (action is LibraryItemAction.Play) {
setMediaId(action.item.id.toString())
setMediaId(Json.encodeToString<LibraryMediaId>(LibraryMediaId.Item(action.item.id, route)))
}
setMediaMetadata(
@@ -126,48 +125,31 @@ class SessionBrowserCallback(
.appendPath(context.resources.getResourceEntryName(this))
.build()
private fun List<LibraryPageElement>.toMediaItems(): List<MediaItem> = flatMap { element ->
private fun List<LibraryPageElement>.toMediaItems(route: LibraryRoute): List<MediaItem> = flatMap { element ->
when (element) {
is LibraryPageElement.Group -> element.items.map { item -> item.toMediaItem(groupTitle = element.title) }
is LibraryPageElement.Item -> listOf(element.toMediaItem())
is LibraryPageElement.Group -> element.items.map { item -> item.toMediaItem(route, groupTitle = element.title) }
is LibraryPageElement.Item -> listOf(element.toMediaItem(route))
}
}
private fun createPageResult(
route: LibraryRoute,
params: LibraryParams? = null,
): LibraryResult<MediaItem> {
val page = route.page
return if (page == null) {
LibraryResult.ofError(
SessionError(SessionError.ERROR_NOT_SUPPORTED, context.getString(R.string.media_service_unknown_page)),
params ?: LibraryParams.Builder().build(),
)
} else {
LibraryResult.ofItem(
MediaItem.Builder().apply {
val extras = Bundle()
val contentStyle = when (page.grid) {
true -> MediaConstants.EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM
false -> MediaConstants.EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM
}
extras.putInt(MediaConstants.EXTRAS_KEY_CONTENT_STYLE_BROWSABLE, contentStyle)
extras.putInt(MediaConstants.EXTRAS_KEY_CONTENT_STYLE_PLAYABLE, contentStyle)
setMediaId(Json.encodeToString(route))
setMediaMetadata(
MediaMetadata.Builder().apply {
setIsBrowsable(true)
setIsPlayable(false)
setExtras(extras)
}.build(),
)
}.build(),
params,
)
private fun createPageMediaItem(route: LibraryRoute, page: LibraryPage<*> = route.page!!) = MediaItem.Builder().apply {
val extras = Bundle()
val contentStyle = when (page.grid ?: false) {
true -> MediaConstants.EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM
false -> MediaConstants.EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM
}
}
extras.putInt(MediaConstants.EXTRAS_KEY_CONTENT_STYLE_BROWSABLE, contentStyle)
extras.putInt(MediaConstants.EXTRAS_KEY_CONTENT_STYLE_PLAYABLE, contentStyle)
setMediaId(Json.encodeToString<LibraryMediaId>(LibraryMediaId.Route(route)))
setMediaMetadata(
MediaMetadata.Builder().apply {
setIsBrowsable(true)
setIsPlayable(false)
setExtras(extras)
}.build(),
)
}.build()
private suspend fun createPageContentResult(
route: LibraryRoute,
@@ -191,7 +173,7 @@ class SessionBrowserCallback(
route,
pageIndex * pageSize,
minOf(pageSize, MAX_PAGE_SIZE),
)?.toMediaItems()
)?.toMediaItems(route)
return if (items == null) {
LibraryResult.ofError(
@@ -214,7 +196,17 @@ class SessionBrowserCallback(
}
Timber.d("onGetLibraryRoot $session $browser $params $route")
createPageResult(route, params)
val page = route.page
if (page == null) {
LibraryResult.ofError(
SessionError(SessionError.ERROR_NOT_SUPPORTED, context.getString(R.string.media_service_unknown_page)),
params ?: LibraryParams.Builder().build(),
)
} else {
LibraryResult.ofItem(createPageMediaItem(route, page), params)
}
}
override fun onGetChildren(
@@ -225,15 +217,17 @@ class SessionBrowserCallback(
pageSize: Int,
params: LibraryParams?,
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> = CoroutineScope(Dispatchers.IO).future {
val route = runCatching { Json.decodeFromString<LibraryRoute>(parentId) }.getOrNull()
Timber.d("onGetChildren $parentId $page $pageSize $params")
if (route == null) {
val libraryMediaId = runCatching { Json.decodeFromString<LibraryMediaId>(parentId) }.getOrNull()
Timber.d("onGetChildren $libraryMediaId")
if (libraryMediaId !is LibraryMediaId.Route) {
LibraryResult.ofError(
SessionError(SessionError.ERROR_BAD_VALUE, context.getString(R.string.media_service_unknown_page)),
)
} else {
createPageContentResult(route, params, page, pageSize)
createPageContentResult(libraryMediaId.route, params, page, pageSize)
}
}
@@ -269,14 +263,26 @@ class SessionBrowserCallback(
): ListenableFuture<LibraryResult<MediaItem>> = CoroutineScope(Dispatchers.IO).future {
Timber.d("onGetItem $session $browser $mediaId")
val itemId = mediaId.toUUIDOrNull()
if (itemId == null) {
LibraryResult.ofError(
val libraryMediaId = runCatching { Json.decodeFromString<LibraryMediaId>(mediaId) }.getOrNull()
when (libraryMediaId) {
is LibraryMediaId.Item -> {
val item by api.userLibraryApi.getItem(itemId = libraryMediaId.itemId)
LibraryResult.ofItem(LibraryPageElement.baseItem(api, item).toMediaItem(libraryMediaId.route), null)
}
is LibraryMediaId.Route -> {
val page = libraryMediaId.route.page
if (page == null) {
LibraryResult.ofError(SessionError(SessionError.ERROR_NOT_SUPPORTED, context.getString(R.string.media_service_unknown_page)))
} else {
LibraryResult.ofItem(createPageMediaItem(libraryMediaId.route, page), null)
}
}
null -> LibraryResult.ofError(
SessionError(SessionError.ERROR_BAD_VALUE, context.getString(R.string.media_service_invalid_id)),
)
} else {
val item by api.userLibraryApi.getItem(itemId = mediaId.toUUID())
LibraryResult.ofItem(LibraryPageElement.baseItem(api, item).toMediaItem(), null)
}
}
@@ -287,9 +293,12 @@ class SessionBrowserCallback(
): ListenableFuture<List<MediaItem>> = CoroutineScope(Dispatchers.IO).future {
Timber.d("onAddMediaItems $mediaSession $controller $mediaItems")
mediaItems.map {
mediaItems.mapNotNull {
val libraryMediaId = runCatching { Json.decodeFromString<LibraryMediaId>(it.mediaId) }.getOrNull()
if (libraryMediaId !is LibraryMediaId.Item) return@mapNotNull null
val playbackUri = api.universalAudioApi.getUniversalAudioStreamUrl(
itemId = it.mediaId.toUUID(),
itemId = libraryMediaId.itemId,
deviceId = api.deviceInfo.id,
maxStreamingBitrate = 140000000,
container = listOf(
@@ -310,7 +319,9 @@ class SessionBrowserCallback(
enableRemoteMedia = true,
)
it.buildUpon().setUri(playbackUri + "&ApiKey=${api.accessToken}").build()
it.buildUpon().apply {
setUri(playbackUri + "&ApiKey=${api.accessToken}")
}.build()
}
}
}