Skip to content

Commit

Permalink
Update jellyfin-sdk-kotlin to v1.6.1 (#1506)
Browse files Browse the repository at this point in the history
* Update dependency org.jellyfin.sdk:jellyfin-core to v1.6.1

Fix build errors and add API authorization headers to exoplayer
since the access token is no longer passed via URL for Android Auto.
See jellyfin/jellyfin-sdk-kotlin#871

* Use SDK for authorization header string building

* Use getItem in MediaSourceResolver

* Don't send authorization header to origins other than jellyfin server

* Fix detekt lint warnings
  • Loading branch information
jakobkukla authored Jan 26, 2025
1 parent 3afd938 commit 70d3cf6
Show file tree
Hide file tree
Showing 9 changed files with 83 additions and 66 deletions.
21 changes: 11 additions & 10 deletions app/src/main/java/org/jellyfin/mobile/app/ApiClientController.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import org.jellyfin.mobile.data.entity.ServerEntity
import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.DeviceInfo
import org.jellyfin.sdk.model.serializer.toUUID

class ApiClientController(
private val appPreferences: AppPreferences,
Expand All @@ -27,7 +26,7 @@ class ApiClientController(
appPreferences.currentServerId = withContext(Dispatchers.IO) {
serverDao.getServerByHostname(hostname)?.id ?: serverDao.insert(hostname)
}
apiClient.baseUrl = hostname
apiClient.update(baseUrl = hostname)
}

suspend fun setupUser(serverId: Long, userId: String, accessToken: String) {
Expand Down Expand Up @@ -69,19 +68,21 @@ class ApiClientController(
}

private fun configureApiClientServer(server: ServerEntity?) {
apiClient.baseUrl = server?.hostname
apiClient.update(baseUrl = server?.hostname)
}

private fun configureApiClientUser(userId: String, accessToken: String) {
apiClient.userId = userId.toUUID()
apiClient.accessToken = accessToken
// Append user id to device id to ensure uniqueness across sessions
apiClient.deviceInfo = baseDeviceInfo.copy(id = baseDeviceInfo.id + userId)
apiClient.update(
accessToken = accessToken,
// Append user id to device id to ensure uniqueness across sessions
deviceInfo = baseDeviceInfo.copy(id = baseDeviceInfo.id + userId),
)
}

private fun resetApiClientUser() {
apiClient.userId = null
apiClient.accessToken = null
apiClient.deviceInfo = baseDeviceInfo
apiClient.update(
accessToken = null,
deviceInfo = baseDeviceInfo,
)
}
}
29 changes: 28 additions & 1 deletion app/src/main/java/org/jellyfin/mobile/app/AppModule.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package org.jellyfin.mobile.app

import android.content.Context
import androidx.core.net.toUri
import coil.ImageLoader
import com.google.android.exoplayer2.ext.cronet.CronetDataSource
import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory
Expand All @@ -11,8 +12,10 @@ import com.google.android.exoplayer2.source.ProgressiveMediaSource
import com.google.android.exoplayer2.source.SingleSampleMediaSource
import com.google.android.exoplayer2.source.hls.HlsMediaSource
import com.google.android.exoplayer2.upstream.DataSource
import com.google.android.exoplayer2.upstream.DataSpec
import com.google.android.exoplayer2.upstream.DefaultDataSource
import com.google.android.exoplayer2.upstream.DefaultHttpDataSource
import com.google.android.exoplayer2.upstream.ResolvingDataSource
import com.google.android.exoplayer2.util.Util
import kotlinx.coroutines.channels.Channel
import okhttp3.OkHttpClient
Expand All @@ -34,6 +37,8 @@ import org.jellyfin.mobile.utils.isLowRamDevice
import org.jellyfin.mobile.webapp.RemoteVolumeProvider
import org.jellyfin.mobile.webapp.WebViewFragment
import org.jellyfin.mobile.webapp.WebappFunctionChannel
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.util.AuthorizationHeaderBuilder
import org.koin.android.ext.koin.androidApplication
import org.koin.androidx.fragment.dsl.fragment
import org.koin.androidx.viewmodel.dsl.viewModel
Expand Down Expand Up @@ -81,6 +86,7 @@ val applicationModule = module {
// ExoPlayer factories
single<DataSource.Factory> {
val context: Context = get()
val apiClient: ApiClient = get()

val provider = CronetProvider.getAllProviders(context).firstOrNull { provider: CronetProvider ->
(provider.name == CronetProvider.PROVIDER_NAME_APP_PACKAGED) && provider.isEnabled
Expand All @@ -102,7 +108,28 @@ val applicationModule = module {
}
}

DefaultDataSource.Factory(context, baseDataSourceFactory)
val dataSourceFactory = DefaultDataSource.Factory(context, baseDataSourceFactory)

// Add authorization header. This is needed as we don't pass the
// access token in the URL for Android Auto.
ResolvingDataSource.Factory(dataSourceFactory) { dataSpec: DataSpec ->
// Only send authorization header if URI matches the jellyfin server
val baseUrlAuthority = apiClient.baseUrl?.toUri()?.authority

if (dataSpec.uri.authority == baseUrlAuthority) {
val authorizationHeaderString = AuthorizationHeaderBuilder.buildHeader(
clientName = apiClient.clientInfo.name,
clientVersion = apiClient.clientInfo.version,
deviceId = apiClient.deviceInfo.id,
deviceName = apiClient.deviceInfo.name,
accessToken = apiClient.accessToken,
)

dataSpec.withRequestHeaders(hashMapOf("Authorization" to authorizationHeaderString))
} else {
dataSpec
}
}
}
single<MediaSource.Factory> {
val context: Context = get()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ import org.jellyfin.sdk.api.operations.HlsSegmentApi
import org.jellyfin.sdk.api.operations.PlayStateApi
import org.jellyfin.sdk.api.operations.UserApi
import org.jellyfin.sdk.model.api.PlayMethod
import org.jellyfin.sdk.model.api.PlaybackOrder
import org.jellyfin.sdk.model.api.PlaybackProgressInfo
import org.jellyfin.sdk.model.api.PlaybackStartInfo
import org.jellyfin.sdk.model.api.PlaybackStopInfo
Expand Down Expand Up @@ -319,6 +320,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application),
positionTicks = mediaSource.startTimeMs * Constants.TICKS_PER_MILLISECOND,
volumeLevel = audioManager.getVolumeLevelPercent(),
repeatMode = RepeatMode.REPEAT_NONE,
playbackOrder = PlaybackOrder.DEFAULT,
),
)
} catch (e: ApiClientException) {
Expand Down Expand Up @@ -347,6 +349,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application),
positionTicks = playbackPositionMillis * Constants.TICKS_PER_MILLISECOND,
volumeLevel = (currentVolume - volumeRange.first) * Constants.PERCENT_MAX / volumeRange.width,
repeatMode = RepeatMode.REPEAT_NONE,
playbackOrder = PlaybackOrder.DEFAULT,
),
)
} catch (e: ApiClientException) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@ import org.jellyfin.mobile.player.cast.ICastPlayerProvider
import org.jellyfin.mobile.utils.Constants
import org.jellyfin.mobile.utils.extensions.mediaUri
import org.jellyfin.mobile.utils.toast
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.exception.ApiClientException
import org.koin.android.ext.android.get
import org.koin.android.ext.android.inject
Expand All @@ -53,7 +52,6 @@ import com.google.android.exoplayer2.MediaItem as ExoPlayerMediaItem

class MediaService : MediaBrowserServiceCompat() {
private val apiClientController: ApiClientController by inject()
private val apiClient: ApiClient by inject()
private val libraryBrowser: LibraryBrowser by inject()

private val serviceScope = MainScope()
Expand Down Expand Up @@ -177,12 +175,7 @@ class MediaService : MediaBrowserServiceCompat() {
loadingJob.join()

val items = try {
if (apiClient.userId != null) {
libraryBrowser.loadLibrary(parentId)
} else {
Timber.e("Missing userId in ApiClient")
null
}
libraryBrowser.loadLibrary(parentId)
} catch (e: ApiClientException) {
Timber.e(e)
null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,11 @@ import org.jellyfin.sdk.api.operations.UserViewsApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.ItemFilter
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.MediaStreamProtocol
import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.serializer.toUUID
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
Expand Down Expand Up @@ -178,10 +180,10 @@ class LibraryBrowser(
Timber.d("Searching for artist $artistQuery")
searchItems(artistQuery, BaseItemKind.MUSIC_ARTIST)
}?.let { artistId ->
itemsApi.getItemsByUserId(
itemsApi.getItems(
artistIds = listOf(artistId),
includeItemTypes = listOf(BaseItemKind.AUDIO),
sortBy = listOf("Random"),
sortBy = listOf(ItemSortBy.RANDOM),
recursive = true,
imageTypeLimit = 1,
enableImageTypes = listOf(ImageType.PRIMARY),
Expand All @@ -197,7 +199,7 @@ class LibraryBrowser(

// Fallback to generic search
Timber.d("Searching for '$searchQuery'")
val result by itemsApi.getItemsByUserId(
val result by itemsApi.getItems(
searchTerm = searchQuery,
includeItemTypes = listOf(BaseItemKind.AUDIO),
recursive = true,
Expand All @@ -214,7 +216,7 @@ class LibraryBrowser(
* Find a single specific item for the given [searchQuery] with a specific [type]
*/
private suspend fun searchItems(searchQuery: String, type: BaseItemKind): UUID? {
val result by itemsApi.getItemsByUserId(
val result by itemsApi.getItems(
searchTerm = searchQuery,
includeItemTypes = listOf(type),
recursive = true,
Expand All @@ -223,7 +225,7 @@ class LibraryBrowser(
limit = 1,
)

return result.items?.firstOrNull()?.id
return result.items.firstOrNull()?.id
}

suspend fun getDefaultRecents(): List<MediaMetadataCompat>? = getLibraries().firstOrNull()?.mediaId?.let { defaultLibrary ->
Expand All @@ -235,8 +237,8 @@ class LibraryBrowser(
private suspend fun getLibraries(): List<MediaBrowserCompat.MediaItem> {
val userViews by userViewsApi.getUserViews()

return userViews.items.orEmpty()
.filter { item -> item.collectionType.equals("music", ignoreCase = true) }
return userViews.items
.filter { item -> item.collectionType == CollectionType.MUSIC }
.map { item ->
val itemImageUrl = imageApi.getItemImageUrl(
itemId = item.id,
Expand Down Expand Up @@ -286,11 +288,11 @@ class LibraryBrowser(
}

private suspend fun getRecents(libraryId: UUID): List<MediaMetadataCompat>? {
val result by itemsApi.getItemsByUserId(
val result by itemsApi.getItems(
parentId = libraryId,
includeItemTypes = listOf(BaseItemKind.AUDIO),
filters = listOf(ItemFilter.IS_PLAYED),
sortBy = listOf("DatePlayed"),
sortBy = listOf(ItemSortBy.DATE_PLAYED),
sortOrder = listOf(SortOrder.DESCENDING),
recursive = true,
imageTypeLimit = 1,
Expand All @@ -307,12 +309,12 @@ class LibraryBrowser(
filterArtist: UUID? = null,
filterGenre: UUID? = null,
): List<MediaBrowserCompat.MediaItem>? {
val result by itemsApi.getItemsByUserId(
val result by itemsApi.getItems(
parentId = libraryId,
artistIds = filterArtist?.let(::listOf),
genreIds = filterGenre?.let(::listOf),
includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM),
sortBy = listOf(ItemFields.SORT_NAME.serialName),
sortBy = listOf(ItemSortBy.SORT_NAME),
recursive = true,
imageTypeLimit = 1,
enableImageTypes = listOf(ImageType.PRIMARY),
Expand All @@ -323,10 +325,10 @@ class LibraryBrowser(
}

private suspend fun getArtists(libraryId: UUID): List<MediaBrowserCompat.MediaItem>? {
val result by itemsApi.getItemsByUserId(
val result by itemsApi.getItems(
parentId = libraryId,
includeItemTypes = listOf(BaseItemKind.MUSIC_ARTIST),
sortBy = listOf(ItemFields.SORT_NAME.serialName),
sortBy = listOf(ItemSortBy.SORT_NAME),
recursive = true,
imageTypeLimit = 1,
enableImageTypes = listOf(ImageType.PRIMARY),
Expand All @@ -338,7 +340,6 @@ class LibraryBrowser(

private suspend fun getGenres(libraryId: UUID): List<MediaBrowserCompat.MediaItem>? {
val result by genresApi.getGenres(
userId = apiClient.userId,
parentId = libraryId,
imageTypeLimit = 1,
enableImageTypes = listOf(ImageType.PRIMARY),
Expand All @@ -349,10 +350,10 @@ class LibraryBrowser(
}

private suspend fun getPlaylists(libraryId: UUID): List<MediaBrowserCompat.MediaItem>? {
val result by itemsApi.getItemsByUserId(
val result by itemsApi.getItems(
parentId = libraryId,
includeItemTypes = listOf(BaseItemKind.PLAYLIST),
sortBy = listOf(ItemFields.SORT_NAME.serialName),
sortBy = listOf(ItemSortBy.SORT_NAME),
recursive = true,
imageTypeLimit = 1,
enableImageTypes = listOf(ImageType.PRIMARY),
Expand All @@ -363,9 +364,9 @@ class LibraryBrowser(
}

private suspend fun getAlbum(albumId: UUID): List<MediaMetadataCompat>? {
val result by itemsApi.getItemsByUserId(
val result by itemsApi.getItems(
parentId = albumId,
sortBy = listOf(ItemFields.SORT_NAME.serialName),
sortBy = listOf(ItemSortBy.SORT_NAME),
)

return result.extractItems("${LibraryPage.ALBUM}|$albumId")
Expand Down Expand Up @@ -404,7 +405,6 @@ class LibraryBrowser(
if (item.type == BaseItemKind.AUDIO) {
val uri = universalAudioApi.getUniversalAudioStreamUrl(
itemId = item.id,
userId = apiClient.userId,
deviceId = apiClient.deviceInfo.id,
maxStreamingBitrate = 140000000,
container = listOf(
Expand All @@ -419,11 +419,10 @@ class LibraryBrowser(
"wav",
"ogg",
),
transcodingProtocol = "hls",
transcodingProtocol = MediaStreamProtocol.HLS,
transcodingContainer = "ts",
audioCodec = "aac",
enableRemoteMedia = true,
includeCredentials = true,
)

builder.setMediaUri(uri)
Expand Down
Loading

0 comments on commit 70d3cf6

Please sign in to comment.