refactor(all/jellyfin): Refactor stuff (#2804)

This commit is contained in:
Secozzi 2024-01-21 18:00:57 +00:00 committed by GitHub
parent ed55a9a3b0
commit c976b048e5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 494 additions and 485 deletions

View File

@ -1,7 +1,7 @@
ext { ext {
extName = 'Jellyfin' extName = 'Jellyfin'
extClass = '.JellyfinFactory' extClass = '.JellyfinFactory'
extVersionCode = 11 extVersionCode = 12
} }
apply from: "$rootDir/common.gradle" apply from: "$rootDir/common.gradle"

View File

@ -1,78 +0,0 @@
package eu.kanade.tachiyomi.animeextension.all.jellyfin
import kotlinx.serialization.Serializable
@Serializable
data class ItemsResponse(
val TotalRecordCount: Int,
val Items: List<Item>,
) {
@Serializable
data class Item(
val Name: String,
val Id: String,
val Type: String,
val LocationType: String,
val ImageTags: ImageObject,
val IndexNumber: Float? = null,
val Genres: List<String>? = null,
val Status: String? = null,
val Studios: List<Studio>? = null,
val SeriesStudio: String? = null,
val Overview: String? = null,
val SeriesName: String? = null,
val SeriesId: String? = null,
) {
@Serializable
data class ImageObject(
val Primary: String? = null,
)
@Serializable
data class Studio(
val Name: String? = null,
)
}
}
@Serializable
data class SessionResponse(
val MediaSources: List<MediaObject>,
val PlaySessionId: String,
) {
@Serializable
data class MediaObject(
val MediaStreams: List<MediaStream>,
) {
@Serializable
data class MediaStream(
val Codec: String,
val Index: Int,
val Type: String,
val SupportsExternalStream: Boolean,
val IsExternal: Boolean,
val Language: String? = null,
val DisplayTitle: String? = null,
val Height: Int? = null,
val Width: Int? = null,
)
}
}
@Serializable
data class LinkData(
val path: String,
val seriesId: String,
val seasonId: String,
)
@Serializable
data class LoginResponse(
val AccessToken: String,
val SessionInfo: SessionObject,
) {
@Serializable
data class SessionObject(
val UserId: String,
)
}

View File

@ -8,6 +8,7 @@ import android.text.InputType
import android.widget.Toast import android.widget.Toast
import androidx.preference.EditTextPreference import androidx.preference.EditTextPreference
import androidx.preference.ListPreference import androidx.preference.ListPreference
import androidx.preference.MultiSelectListPreference
import androidx.preference.PreferenceScreen import androidx.preference.PreferenceScreen
import androidx.preference.SwitchPreferenceCompat import androidx.preference.SwitchPreferenceCompat
import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource
@ -19,28 +20,21 @@ import eu.kanade.tachiyomi.animesource.model.Track
import eu.kanade.tachiyomi.animesource.model.Video import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.animesource.online.AnimeHttpSource import eu.kanade.tachiyomi.animesource.online.AnimeHttpSource
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.awaitSuccess
import eu.kanade.tachiyomi.util.parseAs import eu.kanade.tachiyomi.util.parseAs
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import okhttp3.Dns import okhttp3.Dns
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import org.jsoup.Jsoup
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
import java.security.cert.X509Certificate import java.security.cert.X509Certificate
import javax.net.ssl.SSLContext import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManager import javax.net.ssl.TrustManager
import javax.net.ssl.X509TrustManager import javax.net.ssl.X509TrustManager
import kotlin.math.ceil
import kotlin.math.floor
class Jellyfin(private val suffix: String) : ConfigurableAnimeSource, AnimeHttpSource() { class Jellyfin(private val suffix: String) : ConfigurableAnimeSource, AnimeHttpSource() {
@ -50,8 +44,6 @@ class Jellyfin(private val suffix: String) : ConfigurableAnimeSource, AnimeHttpS
override val supportsLatest = true override val supportsLatest = true
private val json: Json by injectLazy()
private fun getUnsafeOkHttpClient(): OkHttpClient { private fun getUnsafeOkHttpClient(): OkHttpClient {
// Create a trust manager that does not validate certificate chains // Create a trust manager that does not validate certificate chains
val trustAllCerts = arrayOf<TrustManager>( val trustAllCerts = arrayOf<TrustManager>(
@ -81,7 +73,7 @@ class Jellyfin(private val suffix: String) : ConfigurableAnimeSource, AnimeHttpS
} }
override val client by lazy { override val client by lazy {
if (preferences.getBoolean("preferred_trust_all_certs", false)) { if (preferences.getTrustCert) {
getUnsafeOkHttpClient() getUnsafeOkHttpClient()
} else { } else {
network.client network.client
@ -94,13 +86,13 @@ class Jellyfin(private val suffix: String) : ConfigurableAnimeSource, AnimeHttpS
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000) Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
} }
override var baseUrl = JFConstants.getPrefHostUrl(preferences) override var baseUrl = preferences.getHostUrl
private var username = JFConstants.getPrefUsername(preferences) private var username = preferences.getUserName
private var password = JFConstants.getPrefPassword(preferences) private var password = preferences.getPassword
private var parentId = JFConstants.getPrefParentId(preferences) private var parentId = preferences.getMediaLibId
private var apiKey = JFConstants.getPrefApiKey(preferences) private var apiKey = preferences.getApiKey
private var userId = JFConstants.getPrefUserId(preferences) private var userId = preferences.getUserId
init { init {
login(false) login(false)
@ -108,9 +100,9 @@ class Jellyfin(private val suffix: String) : ConfigurableAnimeSource, AnimeHttpS
private fun login(new: Boolean, context: Context? = null): Boolean? { private fun login(new: Boolean, context: Context? = null): Boolean? {
if (apiKey == null || userId == null || new) { if (apiKey == null || userId == null || new) {
baseUrl = JFConstants.getPrefHostUrl(preferences) baseUrl = preferences.getHostUrl
username = JFConstants.getPrefUsername(preferences) username = preferences.getUserName
password = JFConstants.getPrefPassword(preferences) password = preferences.getPassword
if (username.isEmpty() || password.isEmpty()) { if (username.isEmpty() || password.isEmpty()) {
if (username != "demo") return null if (username != "demo") return null
} }
@ -133,314 +125,230 @@ class Jellyfin(private val suffix: String) : ConfigurableAnimeSource, AnimeHttpS
// ============================== Popular =============================== // ============================== Popular ===============================
override fun popularAnimeParse(response: Response): AnimesPage = throw UnsupportedOperationException()
override suspend fun getPopularAnime(page: Int): AnimesPage {
return client.newCall(popularAnimeRequest(page))
.awaitSuccess()
.use { response ->
popularAnimeParsePage(response, page)
}
}
override fun popularAnimeRequest(page: Int): Request { override fun popularAnimeRequest(page: Int): Request {
require(parentId.isNotEmpty()) { "Select library in the extension settings." } require(parentId.isNotEmpty()) { "Select library in the extension settings." }
val startIndex = (page - 1) * 20 val startIndex = (page - 1) * SEASONS_LIMIT
val url = "$baseUrl/Users/$userId/Items".toHttpUrl().newBuilder().apply { val url = "$baseUrl/Users/$userId/Items".toHttpUrl().newBuilder().apply {
addQueryParameter("api_key", apiKey) addQueryParameter("api_key", apiKey)
addQueryParameter("StartIndex", startIndex.toString()) addQueryParameter("StartIndex", startIndex.toString())
addQueryParameter("Limit", "20") addQueryParameter("Limit", SEASONS_LIMIT.toString())
addQueryParameter("Recursive", "true") addQueryParameter("Recursive", "true")
addQueryParameter("SortBy", "SortName") addQueryParameter("SortBy", "SortName")
addQueryParameter("SortOrder", "Ascending") addQueryParameter("SortOrder", "Ascending")
addQueryParameter("includeItemTypes", "Movie,Season,BoxSet") addQueryParameter("IncludeItemTypes", "Movie,Season")
addQueryParameter("ImageTypeLimit", "1") addQueryParameter("ImageTypeLimit", "1")
addQueryParameter("ParentId", parentId) addQueryParameter("ParentId", parentId)
addQueryParameter("EnableImageTypes", "Primary") addQueryParameter("EnableImageTypes", "Primary")
} }.build()
return GET(url.toString()) return GET(url)
} }
private fun popularAnimeParsePage(response: Response, page: Int): AnimesPage { override fun popularAnimeParse(response: Response): AnimesPage {
val (list, hasNext) = animeParse(response, page) val page = response.request.url.queryParameter("StartIndex")!!.toInt() / SEASONS_LIMIT + 1
return AnimesPage( val data = response.parseAs<ItemsDto>()
list.sortedBy { it.title }, val animeList = data.items.map { it.toSAnime(baseUrl, userId!!, apiKey!!) }
hasNext, return AnimesPage(animeList, SEASONS_LIMIT * page < data.itemCount)
)
} }
// =============================== Latest =============================== // =============================== Latest ===============================
override fun latestUpdatesParse(response: Response) = throw UnsupportedOperationException()
override suspend fun getLatestUpdates(page: Int): AnimesPage {
return client.newCall(latestUpdatesRequest(page))
.awaitSuccess()
.use { response ->
latestUpdatesParsePage(response, page)
}
}
override fun latestUpdatesRequest(page: Int): Request { override fun latestUpdatesRequest(page: Int): Request {
require(parentId.isNotEmpty()) { "Select library in the extension settings." } val url = popularAnimeRequest(page).url.newBuilder().apply {
val startIndex = (page - 1) * 20 setQueryParameter("SortBy", "DateCreated,SortName")
setQueryParameter("SortOrder", "Descending")
}.build()
val url = "$baseUrl/Users/$userId/Items".toHttpUrl().newBuilder().apply { return GET(url)
addQueryParameter("api_key", apiKey)
addQueryParameter("StartIndex", startIndex.toString())
addQueryParameter("Limit", "20")
addQueryParameter("Recursive", "true")
addQueryParameter("SortBy", "DateCreated,SortName")
addQueryParameter("SortOrder", "Descending")
addQueryParameter("includeItemTypes", "Movie,Season,BoxSet")
addQueryParameter("ImageTypeLimit", "1")
addQueryParameter("ParentId", parentId)
addQueryParameter("EnableImageTypes", "Primary")
}
return GET(url.toString())
} }
private fun latestUpdatesParsePage(response: Response, page: Int) = animeParse(response, page) override fun latestUpdatesParse(response: Response): AnimesPage =
popularAnimeParse(response)
// =============================== Search =============================== // =============================== Search ===============================
override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request = throw UnsupportedOperationException() override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request {
val url = popularAnimeRequest(page).url.newBuilder().apply {
// Search for series, rather than seasons, since season names can just be "Season 1"
setQueryParameter("IncludeItemTypes", "Movie,Series")
setQueryParameter("Limit", SERIES_LIMIT.toString())
setQueryParameter("SearchTerm", query)
}.build()
override fun searchAnimeParse(response: Response) = throw UnsupportedOperationException() return GET(url)
override suspend fun getSearchAnime(page: Int, query: String, filters: AnimeFilterList): AnimesPage {
require(parentId.isNotEmpty()) { "Select library in the extension settings." }
val startIndex = (page - 1) * 5
val url = "$baseUrl/Users/$userId/Items".toHttpUrl().newBuilder().apply {
addQueryParameter("api_key", apiKey)
addQueryParameter("StartIndex", startIndex.toString())
addQueryParameter("Limit", "5")
addQueryParameter("Recursive", "true")
addQueryParameter("SortBy", "SortName")
addQueryParameter("SortOrder", "Ascending")
addQueryParameter("IncludeItemTypes", "Series,Movie,BoxSet")
addQueryParameter("ImageTypeLimit", "1")
addQueryParameter("EnableImageTypes", "Primary")
addQueryParameter("ParentId", parentId)
addQueryParameter("SearchTerm", query)
}
val items = client.newCall(
GET(url.build().toString(), headers = headers),
).execute().parseAs<ItemsResponse>()
val movieList = items.Items.filter { it.Type == "Movie" }
val nonMovieList = items.Items.filter { it.Type != "Movie" }
val animeList = getAnimeFromMovie(movieList) + nonMovieList.flatMap {
getAnimeFromId(it.Id)
}
return AnimesPage(animeList, 5 * page < items.TotalRecordCount)
} }
private fun getAnimeFromMovie(movieList: List<ItemsResponse.Item>): List<SAnime> { override fun searchAnimeParse(response: Response): AnimesPage {
return movieList.map { val page = response.request.url.queryParameter("StartIndex")!!.toInt() / SERIES_LIMIT + 1
SAnime.create().apply { val data = response.parseAs<ItemsDto>()
title = it.Name
thumbnail_url = "$baseUrl/Items/${it.Id}/Images/Primary?api_key=$apiKey"
url = LinkData(
"/Users/$userId/Items/${it.Id}?api_key=$apiKey",
it.Id,
it.Id,
).toJsonString()
}
}
}
private fun getAnimeFromId(id: String): List<SAnime> { // Get all seasons from series
val url = "$baseUrl/Users/$userId/Items".toHttpUrl().newBuilder().apply { val animeList = data.items.flatMap { series ->
addQueryParameter("api_key", apiKey) val seasonsUrl = popularAnimeRequest(1).url.newBuilder().apply {
addQueryParameter("Recursive", "true") setQueryParameter("ParentId", series.id)
addQueryParameter("SortBy", "SortName") removeAllQueryParameters("StartIndex")
addQueryParameter("SortOrder", "Ascending") removeAllQueryParameters("Limit")
addQueryParameter("includeItemTypes", "Movie,Series,Season") }.build()
addQueryParameter("ImageTypeLimit", "1")
addQueryParameter("EnableImageTypes", "Primary") val seasonsData = client.newCall(
addQueryParameter("ParentId", id) GET(seasonsUrl),
).execute().parseAs<ItemsDto>()
seasonsData.items.map { it.toSAnime(baseUrl, userId!!, apiKey!!) }
} }
val response = client.newCall( return AnimesPage(animeList, SERIES_LIMIT * page < data.itemCount)
GET(url.build().toString()),
).execute()
return animeParse(response, 0).animes
} }
// =========================== Anime Details ============================ // =========================== Anime Details ============================
override fun animeDetailsRequest(anime: SAnime): Request { override fun animeDetailsRequest(anime: SAnime): Request {
val mediaId = json.decodeFromString<LinkData>(anime.url) if (!anime.url.startsWith("http")) throw Exception("Migrate from jellyfin to jellyfin")
return GET(anime.url)
val infoId = if (preferences.getBoolean("preferred_meta_type", false)) {
mediaId.seriesId
} else {
mediaId.seasonId
}
val url = "$baseUrl/Users/$userId/Items/$infoId".toHttpUrl().newBuilder().apply {
addQueryParameter("api_key", apiKey)
addQueryParameter("fields", "Studios")
}
return GET(url.toString())
} }
override fun animeDetailsParse(response: Response): SAnime { override fun animeDetailsParse(response: Response): SAnime {
val info = response.parseAs<ItemsResponse.Item>() val data = response.parseAs<ItemDto>()
val infoData = if (preferences.useSeriesData && data.seriesId != null) {
val url = response.request.url.let { url ->
url.newBuilder().apply {
removePathSegment(url.pathSize - 1)
addPathSegment(data.seriesId)
}.build()
}
val anime = SAnime.create() client.newCall(
GET(url),
if (info.Genres != null) anime.genre = info.Genres.joinToString(", ") ).execute().parseAs<ItemDto>()
if (!info.Studios.isNullOrEmpty()) {
anime.author = info.Studios.mapNotNull { it.Name }.joinToString(", ")
} else if (info.SeriesStudio != null) anime.author = info.SeriesStudio
anime.description = if (info.Overview != null) {
Jsoup.parse(
info.Overview
.replace("<br>\n", "br2n")
.replace("<br>", "br2n")
.replace("\n", "br2n"),
).text().replace("br2n", "\n")
} else { } else {
"" data
} }
if (info.Type == "Movie") { return infoData.toSAnime(baseUrl, userId!!, apiKey!!)
anime.status = SAnime.COMPLETED
}
anime.title = if (info.SeriesName == null) {
info.Name
} else {
"${info.SeriesName} ${info.Name}"
}
return anime
} }
// ============================== Episodes ============================== // ============================== Episodes ==============================
override fun episodeListRequest(anime: SAnime): Request { override fun episodeListRequest(anime: SAnime): Request {
val mediaId = json.decodeFromString<LinkData>(anime.url) if (!anime.url.startsWith("http")) throw Exception("Migrate from jellyfin to jellyfin")
return GET(baseUrl + mediaId.path, headers = headers) val httpUrl = anime.url.toHttpUrl()
val fragment = httpUrl.fragment!!
// Get episodes of season
val url = if (fragment.startsWith("seriesId")) {
httpUrl.newBuilder().apply {
encodedPath("/")
encodedQuery(null)
fragment(null)
addPathSegment("Shows")
addPathSegment(fragment.split(",").last())
addPathSegment("Episodes")
addQueryParameter("api_key", apiKey)
addQueryParameter("seasonId", httpUrl.pathSegments.last())
addQueryParameter("userId", userId)
addQueryParameter("Fields", "Overview,MediaSources")
}.build()
} else if (fragment.startsWith("movie")) {
httpUrl.newBuilder().fragment(null).build()
} else {
httpUrl
}
return GET(url)
} }
override fun episodeListParse(response: Response): List<SEpisode> { override fun episodeListParse(response: Response): List<SEpisode> {
val epDetails = preferences.getEpDetails
val episodeList = if (response.request.url.toString().startsWith("$baseUrl/Users/")) { val episodeList = if (response.request.url.toString().startsWith("$baseUrl/Users/")) {
val parsed = json.decodeFromString<ItemsResponse.Item>(response.body.string()) val data = response.parseAs<ItemDto>()
listOf( listOf(data.toSEpisode(baseUrl, userId!!, apiKey!!, epDetails, EpisodeType.MOVIE))
SEpisode.create().apply {
setUrlWithoutDomain(response.request.url.toString())
name = "Movie ${parsed.Name}"
episode_number = 1.0F
},
)
} else { } else {
val parsed = response.parseAs<ItemsResponse>() val data = response.parseAs<ItemsDto>()
data.items.map {
parsed.Items.map { ep -> it.toSEpisode(baseUrl, userId!!, apiKey!!, epDetails, EpisodeType.EPISODE)
val namePrefix = if (ep.IndexNumber == null) {
""
} else {
val formattedEpNum = if (floor(ep.IndexNumber) == ceil(ep.IndexNumber)) {
ep.IndexNumber.toInt()
} else {
ep.IndexNumber.toFloat()
}
"Episode $formattedEpNum "
}
SEpisode.create().apply {
name = "$namePrefix${ep.Name}"
episode_number = ep.IndexNumber ?: 0F
url = "/Users/$userId/Items/${ep.Id}?api_key=$apiKey"
}
} }
} }
return episodeList.reversed() return episodeList.reversed()
} }
enum class EpisodeType {
EPISODE,
MOVIE,
}
// ============================ Video Links ============================= // ============================ Video Links =============================
override fun videoListRequest(episode: SEpisode): Request {
if (!episode.url.startsWith("http")) throw Exception("Migrate from jellyfin to jellyfin")
return GET(episode.url)
}
override fun videoListParse(response: Response): List<Video> { override fun videoListParse(response: Response): List<Video> {
val videoList = mutableListOf<Video>() val id = response.parseAs<ItemDto>().id
val id = response.parseAs<ItemsResponse.Item>().Id
val parsed = client.newCall( val sessionData = client.newCall(
GET("$baseUrl/Items/$id/PlaybackInfo?userId=$userId&api_key=$apiKey"), GET("$baseUrl/Items/$id/PlaybackInfo?userId=$userId&api_key=$apiKey"),
).execute().parseAs<SessionResponse>() ).execute().parseAs<SessionDto>()
val videoList = mutableListOf<Video>()
val subtitleList = mutableListOf<Track>() val subtitleList = mutableListOf<Track>()
val externalSubtitleList = mutableListOf<Track>() val externalSubtitleList = mutableListOf<Track>()
val prefSub = preferences.getString(JFConstants.PREF_SUB_KEY, "eng")!! val prefSub = preferences.getSubPref
val prefAudio = preferences.getString(JFConstants.PREF_AUDIO_KEY, "jpn")!! val prefAudio = preferences.getAudioPref
var audioIndex = 1 var audioIndex = 1
var subIndex: Int? = null var subIndex: Int? = null
var width = 1920 var width = 1920
var height = 1080 var height = 1080
parsed.MediaSources.first().MediaStreams.forEach { media -> sessionData.mediaSources.first().mediaStreams.forEach { media ->
when (media.Type) { when (media.type) {
"Subtitle" -> { "Video" -> {
if (media.SupportsExternalStream) { width = media.width!!
val subUrl = "$baseUrl/Videos/$id/$id/Subtitles/${media.Index}/0/Stream.${media.Codec}?api_key=$apiKey" height = media.height!!
if (media.Language != null) {
if (media.Language == prefSub) {
try {
if (media.IsExternal) {
externalSubtitleList.add(0, Track(subUrl, media.DisplayTitle!!))
}
subtitleList.add(0, Track(subUrl, media.DisplayTitle!!))
} catch (e: Error) {
subIndex = media.Index
}
} else {
if (media.IsExternal) {
externalSubtitleList.add(Track(subUrl, media.DisplayTitle!!))
}
subtitleList.add(Track(subUrl, media.DisplayTitle!!))
}
} else {
if (media.IsExternal) {
externalSubtitleList.add(Track(subUrl, media.DisplayTitle!!))
}
subtitleList.add(Track(subUrl, media.DisplayTitle!!))
}
} else {
if (media.Language != null && media.Language == prefSub) {
subIndex = media.Index
}
}
} }
"Audio" -> { "Audio" -> {
if (media.Language != null && media.Language == prefAudio) { if (media.lang != null && media.lang == prefAudio) {
audioIndex = media.Index audioIndex = media.index
} }
} }
"Video" -> { "Subtitle" -> {
width = media.Width!! if (media.supportsExternalStream) {
height = media.Height!! val subtitleUrl = "$baseUrl/Videos/$id/$id/Subtitles/${media.index}/0/Stream.${media.codec}?api_key=$apiKey"
if (media.lang != null) {
if (media.lang == prefSub) {
try {
if (media.isExternal) {
externalSubtitleList.add(0, Track(subtitleUrl, media.displayTitle!!))
}
subtitleList.add(0, Track(subtitleUrl, media.displayTitle!!))
} catch (e: Exception) {
subIndex = media.index
}
} else {
if (media.isExternal) {
externalSubtitleList.add(Track(subtitleUrl, media.displayTitle!!))
}
subtitleList.add(Track(subtitleUrl, media.displayTitle!!))
}
} else {
if (media.isExternal) {
externalSubtitleList.add(Track(subtitleUrl, media.displayTitle!!))
}
subtitleList.add(Track(subtitleUrl, media.displayTitle!!))
}
}
} }
} }
} }
// Loop over qualities // Loop over qualities
JFConstants.QUALITIES_LIST.forEach { quality -> JellyfinConstants.QUALITIES_LIST.forEach { quality ->
if (width < quality.width && height < quality.height) { if (width < quality.width && height < quality.height) {
val url = "$baseUrl/Videos/$id/stream?static=True&api_key=$apiKey" val url = "$baseUrl/Videos/$id/stream?static=True&api_key=$apiKey"
videoList.add(Video(url, "Source", url, subtitleTracks = externalSubtitleList)) videoList.add(Video(url, "Source", url, subtitleTracks = externalSubtitleList))
@ -463,7 +371,7 @@ class Jellyfin(private val suffix: String) : ConfigurableAnimeSource, AnimeHttpS
"AudioBitrate", "AudioBitrate",
quality.audioBitrate.toString(), quality.audioBitrate.toString(),
) )
addQueryParameter("PlaySessionId", parsed.PlaySessionId) addQueryParameter("PlaySessionId", sessionData.playSessionId)
addQueryParameter("TranscodingMaxAudioChannels", "6") addQueryParameter("TranscodingMaxAudioChannels", "6")
addQueryParameter("RequireAvc", "false") addQueryParameter("RequireAvc", "false")
addQueryParameter("SegmentContainer", "ts") addQueryParameter("SegmentContainer", "ts")
@ -474,7 +382,6 @@ class Jellyfin(private val suffix: String) : ConfigurableAnimeSource, AnimeHttpS
addQueryParameter("h264-deinterlace", "true") addQueryParameter("h264-deinterlace", "true")
addQueryParameter("TranscodeReasons", "VideoCodecNotSupported,AudioCodecNotSupported,ContainerBitrateExceedsLimit") addQueryParameter("TranscodeReasons", "VideoCodecNotSupported,AudioCodecNotSupported,ContainerBitrateExceedsLimit")
} }
videoList.add(Video(url.toString(), quality.description, url.toString(), subtitleTracks = subtitleList)) videoList.add(Video(url.toString(), quality.description, url.toString(), subtitleTracks = subtitleList))
} }
} }
@ -487,83 +394,49 @@ class Jellyfin(private val suffix: String) : ConfigurableAnimeSource, AnimeHttpS
// ============================= Utilities ============================== // ============================= Utilities ==============================
private fun animeParse(response: Response, page: Int): AnimesPage { companion object {
val items = response.parseAs<ItemsResponse>() const val APIKEY_KEY = "api_key"
const val USERID_KEY = "user_id"
val animeList = items.Items.flatMap { item -> private const val HOSTURL_KEY = "host_url"
val anime = SAnime.create() private const val HOSTURL_DEFAULT = "http://127.0.0.1:8096"
when (item.Type) { private const val USERNAME_KEY = "username"
"Season" -> { private const val USERNAME_DEFAULT = ""
anime.setUrlWithoutDomain(
LinkData(
path = "/Shows/${item.SeriesId}/Episodes?SeasonId=${item.Id}&api_key=$apiKey",
seriesId = item.SeriesId!!,
seasonId = item.Id,
).toJsonString(),
)
// Virtual if show doesn't have any sub-folders, i.e. no seasons
if (item.LocationType == "Virtual") {
anime.title = item.SeriesName!!
anime.thumbnail_url = "$baseUrl/Items/${item.SeriesId}/Images/Primary?api_key=$apiKey"
} else {
anime.title = "${item.SeriesName} ${item.Name}"
anime.thumbnail_url = "$baseUrl/Items/${item.Id}/Images/Primary?api_key=$apiKey"
}
// If season doesn't have image, fallback to series image private const val PASSWORD_KEY = "password"
if (item.ImageTags.Primary == null) { private const val PASSWORD_DEFAULT = ""
anime.thumbnail_url = "$baseUrl/Items/${item.SeriesId}/Images/Primary?api_key=$apiKey"
}
listOf(anime)
}
"Movie" -> {
anime.title = item.Name
anime.thumbnail_url = "$baseUrl/Items/${item.Id}/Images/Primary?api_key=$apiKey"
anime.setUrlWithoutDomain(
LinkData(
"/Users/$userId/Items/${item.Id}?api_key=$apiKey",
item.Id,
item.Id,
).toJsonString(),
)
listOf(anime)
}
"BoxSet" -> {
val url = "$baseUrl/Users/$userId/Items".toHttpUrl().newBuilder().apply {
addQueryParameter("api_key", apiKey)
addQueryParameter("Recursive", "true")
addQueryParameter("SortBy", "SortName")
addQueryParameter("SortOrder", "Ascending")
addQueryParameter("includeItemTypes", "Movie,Series,Season")
addQueryParameter("ImageTypeLimit", "1")
addQueryParameter("ParentId", item.Id)
addQueryParameter("EnableImageTypes", "Primary")
}
val response = client.newCall( private const val MEDIALIB_KEY = "library_pref"
GET(url.build().toString(), headers = headers), private const val MEDIALIB_DEFAULT = ""
).execute()
animeParse(response, page).animes
}
else -> emptyList()
}
}
return AnimesPage(animeList, 20 * page < items.TotalRecordCount) private const val SEASONS_LIMIT = 20
} private const val SERIES_LIMIT = 5
private fun LinkData.toJsonString(): String { private const val PREF_EP_DETAILS_KEY = "pref_episode_details_key"
return json.encodeToString(this) private val PREF_EP_DETAILS = arrayOf("Overview", "Runtime", "Size")
private val PREF_EP_DETAILS_DEFAULT = emptySet<String>()
private const val PREF_SUB_KEY = "preferred_subLang"
private const val PREF_SUB_DEFAULT = "eng"
private const val PREF_AUDIO_KEY = "preferred_audioLang"
private const val PREF_AUDIO_DEFAULT = "jpn"
private const val PREF_INFO_TYPE = "preferred_meta_type"
private const val PREF_INFO_DEFAULT = false
private const val PREF_TRUST_CERT_KEY = "preferred_trust_all_certs"
private const val PREF_TRUST_CERT_DEFAULT = false
} }
override fun setupPreferenceScreen(screen: PreferenceScreen) { override fun setupPreferenceScreen(screen: PreferenceScreen) {
val mediaLibPref = medialibPreference(screen) val mediaLibPref = medialibPreference(screen)
screen.addPreference( screen.addPreference(
screen.editTextPreference( screen.editTextPreference(
JFConstants.HOSTURL_KEY, HOSTURL_KEY,
JFConstants.HOSTURL_TITLE, "Host URL",
JFConstants.HOSTURL_DEFAULT, HOSTURL_DEFAULT,
baseUrl, baseUrl,
false, false,
"", "",
@ -572,20 +445,20 @@ class Jellyfin(private val suffix: String) : ConfigurableAnimeSource, AnimeHttpS
) )
screen.addPreference( screen.addPreference(
screen.editTextPreference( screen.editTextPreference(
JFConstants.USERNAME_KEY, USERNAME_KEY,
JFConstants.USERNAME_TITLE, "Username",
"", USERNAME_DEFAULT,
username, username,
false, false,
"", "The account username",
mediaLibPref, mediaLibPref,
), ),
) )
screen.addPreference( screen.addPreference(
screen.editTextPreference( screen.editTextPreference(
JFConstants.PASSWORD_KEY, PASSWORD_KEY,
JFConstants.PASSWORD_TITLE, "Password",
"", PASSWORD_DEFAULT,
password, password,
true, true,
"••••••••", "••••••••",
@ -593,12 +466,27 @@ class Jellyfin(private val suffix: String) : ConfigurableAnimeSource, AnimeHttpS
), ),
) )
screen.addPreference(mediaLibPref) screen.addPreference(mediaLibPref)
val subLangPref = ListPreference(screen.context).apply {
key = JFConstants.PREF_SUB_KEY MultiSelectListPreference(screen.context).apply {
title = JFConstants.PREF_SUB_TITLE key = PREF_EP_DETAILS_KEY
entries = JFConstants.PREF_ENTRIES title = "Additional details for episodes"
entryValues = JFConstants.PREF_VALUES summary = "Show additional details about an episode in the scanlator field"
setDefaultValue("eng") entries = PREF_EP_DETAILS
entryValues = PREF_EP_DETAILS
setDefaultValue(PREF_EP_DETAILS_DEFAULT)
setOnPreferenceChangeListener { _, newValue ->
@Suppress("UNCHECKED_CAST")
preferences.edit().putStringSet(key, newValue as Set<String>).commit()
}
}.also(screen::addPreference)
ListPreference(screen.context).apply {
key = PREF_SUB_KEY
title = "Preferred sub language"
entries = JellyfinConstants.PREF_ENTRIES
entryValues = JellyfinConstants.PREF_VALUES
setDefaultValue(PREF_SUB_DEFAULT)
summary = "%s" summary = "%s"
setOnPreferenceChangeListener { _, newValue -> setOnPreferenceChangeListener { _, newValue ->
@ -607,14 +495,14 @@ class Jellyfin(private val suffix: String) : ConfigurableAnimeSource, AnimeHttpS
val entry = entryValues[index] as String val entry = entryValues[index] as String
preferences.edit().putString(key, entry).commit() preferences.edit().putString(key, entry).commit()
} }
} }.also(screen::addPreference)
screen.addPreference(subLangPref)
val audioLangPref = ListPreference(screen.context).apply { ListPreference(screen.context).apply {
key = JFConstants.PREF_AUDIO_KEY key = PREF_AUDIO_KEY
title = JFConstants.PREF_AUDIO_TITLE title = "Preferred audio language"
entries = JFConstants.PREF_ENTRIES entries = JellyfinConstants.PREF_ENTRIES
entryValues = JFConstants.PREF_VALUES entryValues = JellyfinConstants.PREF_VALUES
setDefaultValue("jpn") setDefaultValue(PREF_AUDIO_DEFAULT)
summary = "%s" summary = "%s"
setOnPreferenceChangeListener { _, newValue -> setOnPreferenceChangeListener { _, newValue ->
@ -623,36 +511,66 @@ class Jellyfin(private val suffix: String) : ConfigurableAnimeSource, AnimeHttpS
val entry = entryValues[index] as String val entry = entryValues[index] as String
preferences.edit().putString(key, entry).commit() preferences.edit().putString(key, entry).commit()
} }
} }.also(screen::addPreference)
screen.addPreference(audioLangPref)
val metaTypePref = SwitchPreferenceCompat(screen.context).apply { SwitchPreferenceCompat(screen.context).apply {
key = "preferred_meta_type" key = PREF_INFO_TYPE
title = "Retrieve metadata from series" title = "Retrieve metadata from series"
summary = """Enable this to retrieve metadata from series instead of season when applicable.""".trimMargin() summary = """Enable this to retrieve metadata from series instead of season when applicable.""".trimMargin()
setDefaultValue(false) setDefaultValue(PREF_INFO_DEFAULT)
setOnPreferenceChangeListener { _, newValue -> setOnPreferenceChangeListener { _, newValue ->
val new = newValue as Boolean val new = newValue as Boolean
preferences.edit().putBoolean(key, new).commit() preferences.edit().putBoolean(key, new).commit()
} }
} }.also(screen::addPreference)
screen.addPreference(metaTypePref)
val trustCertificatePref = SwitchPreferenceCompat(screen.context).apply { SwitchPreferenceCompat(screen.context).apply {
key = "preferred_trust_all_certs" key = PREF_TRUST_CERT_KEY
title = "Trust all certificates" title = "Trust all certificates"
summary = "Requires app restart to take effect." summary = "Requires app restart to take effect."
setDefaultValue(false) setDefaultValue(PREF_TRUST_CERT_DEFAULT)
setOnPreferenceChangeListener { _, newValue -> setOnPreferenceChangeListener { _, newValue ->
val new = newValue as Boolean val new = newValue as Boolean
preferences.edit().putBoolean(key, new).commit() preferences.edit().putBoolean(key, new).commit()
} }
} }.also(screen::addPreference)
screen.addPreference(trustCertificatePref)
} }
private val SharedPreferences.getApiKey
get() = getString(APIKEY_KEY, null)
private val SharedPreferences.getUserId
get() = getString(USERID_KEY, null)
private val SharedPreferences.getHostUrl
get() = getString(HOSTURL_KEY, HOSTURL_DEFAULT)!!
private val SharedPreferences.getUserName
get() = getString(USERNAME_KEY, USERNAME_DEFAULT)!!
private val SharedPreferences.getPassword
get() = getString(PASSWORD_KEY, PASSWORD_DEFAULT)!!
private val SharedPreferences.getMediaLibId
get() = getString(MEDIALIB_KEY, MEDIALIB_DEFAULT)!!
private val SharedPreferences.getEpDetails
get() = getStringSet(PREF_EP_DETAILS_KEY, PREF_EP_DETAILS_DEFAULT)!!
private val SharedPreferences.getSubPref
get() = getString(PREF_SUB_KEY, PREF_SUB_DEFAULT)!!
private val SharedPreferences.getAudioPref
get() = getString(PREF_AUDIO_KEY, PREF_AUDIO_DEFAULT)!!
private val SharedPreferences.useSeriesData
get() = getBoolean(PREF_INFO_TYPE, PREF_INFO_DEFAULT)
private val SharedPreferences.getTrustCert
get() = getBoolean(PREF_TRUST_CERT_KEY, PREF_TRUST_CERT_DEFAULT)
private abstract class MediaLibPreference(context: Context) : ListPreference(context) { private abstract class MediaLibPreference(context: Context) : ListPreference(context) {
abstract fun reload() abstract fun reload()
} }
@ -661,8 +579,8 @@ class Jellyfin(private val suffix: String) : ConfigurableAnimeSource, AnimeHttpS
object : MediaLibPreference(screen.context) { object : MediaLibPreference(screen.context) {
override fun reload() { override fun reload() {
this.apply { this.apply {
key = JFConstants.MEDIALIB_KEY key = MEDIALIB_KEY
title = JFConstants.MEDIALIB_TITLE title = "Select Media Library"
summary = "%s" summary = "%s"
Thread { Thread {
@ -670,17 +588,10 @@ class Jellyfin(private val suffix: String) : ConfigurableAnimeSource, AnimeHttpS
val mediaLibsResponse = client.newCall( val mediaLibsResponse = client.newCall(
GET("$baseUrl/Users/$userId/Items?api_key=$apiKey"), GET("$baseUrl/Users/$userId/Items?api_key=$apiKey"),
).execute() ).execute()
val mediaJson = mediaLibsResponse.body.let { json.decodeFromString<ItemsResponse>(it.string()) }?.Items val mediaJson = mediaLibsResponse.parseAs<ItemsDto>().items
val entriesArray = mutableListOf<String>() val entriesArray = mediaJson.map { it.name }
val entriesValueArray = mutableListOf<String>() val entriesValueArray = mediaJson.map { it.id }
if (mediaJson != null) {
for (media in mediaJson) {
entriesArray.add(media.Name)
entriesValueArray.add(media.Id)
}
}
entries = entriesArray.toTypedArray() entries = entriesArray.toTypedArray()
entryValues = entriesValueArray.toTypedArray() entryValues = entriesValueArray.toTypedArray()

View File

@ -4,6 +4,8 @@ import android.content.SharedPreferences
import android.os.Build import android.os.Build
import android.util.Log import android.util.Log
import eu.kanade.tachiyomi.AppInfo import eu.kanade.tachiyomi.AppInfo
import eu.kanade.tachiyomi.animeextension.all.jellyfin.Jellyfin.Companion.APIKEY_KEY
import eu.kanade.tachiyomi.animeextension.all.jellyfin.Jellyfin.Companion.USERID_KEY
import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.util.parseAs import eu.kanade.tachiyomi.util.parseAs
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
@ -27,8 +29,8 @@ class JellyfinAuthenticator(
fun login(username: String, password: String): Pair<String?, String?> { fun login(username: String, password: String): Pair<String?, String?> {
return runCatching { return runCatching {
val authResult = authenticateWithPassword(username, password) val authResult = authenticateWithPassword(username, password)
val key = authResult.AccessToken val key = authResult.accessToken
val userId = authResult.SessionInfo.UserId val userId = authResult.sessionInfo.userId
saveLogin(key, userId) saveLogin(key, userId)
Pair(key, userId) Pair(key, userId)
}.getOrElse { }.getOrElse {
@ -37,7 +39,7 @@ class JellyfinAuthenticator(
} }
} }
private fun authenticateWithPassword(username: String, password: String): LoginResponse { private fun authenticateWithPassword(username: String, password: String): LoginDto {
var deviceId = getPrefDeviceId() var deviceId = getPrefDeviceId()
if (deviceId.isNullOrEmpty()) { if (deviceId.isNullOrEmpty()) {
deviceId = getRandomString() deviceId = getRandomString()
@ -69,8 +71,8 @@ class JellyfinAuthenticator(
private fun saveLogin(key: String, userId: String) { private fun saveLogin(key: String, userId: String) {
preferences.edit() preferences.edit()
.putString(JFConstants.APIKEY_KEY, key) .putString(APIKEY_KEY, key)
.putString(JFConstants.USERID_KEY, userId) .putString(USERID_KEY, userId)
.apply() .apply()
} }

View File

@ -1,51 +1,6 @@
package eu.kanade.tachiyomi.animeextension.all.jellyfin package eu.kanade.tachiyomi.animeextension.all.jellyfin
import android.content.SharedPreferences object JellyfinConstants {
object JFConstants {
const val APIKEY_KEY = "api_key"
const val USERID_KEY = "user_id"
const val USERNAME_TITLE = "Username"
const val USERNAME_KEY = "username"
const val PASSWORD_TITLE = "Password"
const val PASSWORD_KEY = "password"
const val HOSTURL_TITLE = "Host URL"
const val HOSTURL_KEY = "host_url"
const val MEDIALIB_KEY = "library_pref"
const val MEDIALIB_TITLE = "Select Media Library"
const val HOSTURL_DEFAULT = "http://127.0.0.1:8096"
fun getPrefApiKey(preferences: SharedPreferences): String? = preferences.getString(
APIKEY_KEY,
null,
)
fun getPrefUserId(preferences: SharedPreferences): String? = preferences.getString(
USERID_KEY,
null,
)
fun getPrefHostUrl(preferences: SharedPreferences): String = preferences.getString(
HOSTURL_KEY,
HOSTURL_DEFAULT,
)!!
fun getPrefUsername(preferences: SharedPreferences): String = preferences.getString(
USERNAME_KEY,
"",
)!!
fun getPrefPassword(preferences: SharedPreferences): String = preferences.getString(
PASSWORD_KEY,
"",
)!!
fun getPrefParentId(preferences: SharedPreferences): String = preferences.getString(
MEDIALIB_KEY,
"",
)!!
const val PREF_AUDIO_KEY = "preferred_audioLang"
const val PREF_AUDIO_TITLE = "Preferred audio language"
const val PREF_SUB_KEY = "preferred_subLang"
const val PREF_SUB_TITLE = "Preferred sub language"
val QUALITIES_LIST = arrayOf( val QUALITIES_LIST = arrayOf(
Quality(480, 360, 292000, 128000, "360p - 420 kbps"), Quality(480, 360, 292000, 128000, "360p - 420 kbps"),
Quality(854, 480, 528000, 192000, "480p - 720 kbps"), Quality(854, 480, 528000, 192000, "480p - 720 kbps"),

View File

@ -0,0 +1,219 @@
package eu.kanade.tachiyomi.animeextension.all.jellyfin
import eu.kanade.tachiyomi.animeextension.all.jellyfin.Jellyfin.EpisodeType
import eu.kanade.tachiyomi.animesource.model.SAnime
import eu.kanade.tachiyomi.animesource.model.SEpisode
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.jsoup.Jsoup
import java.text.SimpleDateFormat
import java.util.Locale
@Serializable
data class LoginDto(
@SerialName("AccessToken") val accessToken: String,
@SerialName("SessionInfo") val sessionInfo: LoginSessionDto,
) {
@Serializable
data class LoginSessionDto(
@SerialName("UserId") val userId: String,
)
}
@Serializable
data class ItemsDto(
@SerialName("Items") val items: List<ItemDto>,
@SerialName("TotalRecordCount") val itemCount: Int,
)
@Serializable
data class ItemDto(
@SerialName("Name") val name: String,
@SerialName("Type") val type: String,
@SerialName("Id") val id: String,
@SerialName("LocationType") val locationType: String,
@SerialName("ImageTags") val imageTags: ImageDto,
@SerialName("SeriesId") val seriesId: String? = null,
@SerialName("SeriesName") val seriesName: String? = null,
// Details
@SerialName("Overview") val overview: String? = null,
@SerialName("Genres") val genres: List<String>? = null,
@SerialName("Studios") val studios: List<StudioDto>? = null,
// Only for series, not season
@SerialName("Status") val seriesStatus: String? = null,
// Episode
@SerialName("PremiereDate") val premiereData: String? = null,
@SerialName("RunTimeTicks") val runTime: Long? = null,
@SerialName("MediaSources") val mediaSources: List<MediaDto>? = null,
@SerialName("IndexNumber") val indexNumber: Int? = null,
) {
@Serializable
data class ImageDto(
@SerialName("Primary") val primary: String? = null,
)
@Serializable
data class StudioDto(
@SerialName("Name") val name: String,
)
fun toSAnime(baseUrl: String, userId: String, apiKey: String): SAnime = SAnime.create().apply {
val httpUrl = baseUrl.toHttpUrl().newBuilder().apply {
addPathSegment("Users")
addPathSegment(userId)
addPathSegment("Items")
addPathSegment(id)
addQueryParameter("api_key", apiKey)
}
thumbnail_url = "$baseUrl/Items/$id/Images/Primary?api_key=$apiKey"
when (type) {
"Season" -> {
// To prevent one extra GET request when fetching episodes
httpUrl.fragment("seriesId,${seriesId!!}")
if (locationType == "Virtual") {
title = seriesName!!
thumbnail_url = "$baseUrl/Items/$seriesId/Images/Primary?api_key=$apiKey"
} else {
title = "$seriesName $name"
}
// Use series as fallback
if (imageTags.primary == null) {
thumbnail_url = "$baseUrl/Items/$seriesId/Images/Primary?api_key=$apiKey"
}
}
"Movie" -> {
httpUrl.fragment("movie")
title = name
}
}
url = httpUrl.build().toString()
// Details
description = overview?.let {
Jsoup.parseBodyFragment(
it.replace("<br>", "br2n"),
).text().replace("br2n", "\n")
}
genre = genres?.joinToString(", ")
author = studios?.joinToString(", ") { it.name }
status = seriesStatus.parseStatus()
}
private fun String?.parseStatus(): Int = when (this) {
"Ended" -> SAnime.COMPLETED
"Continuing" -> SAnime.ONGOING
else -> SAnime.UNKNOWN
}
// ============================== Episodes ==============================
fun toSEpisode(
baseUrl: String,
userId: String,
apiKey: String,
epDetails: Set<String>,
epType: EpisodeType,
): SEpisode = SEpisode.create().apply {
when (epType) {
EpisodeType.MOVIE -> {
episode_number = 1F
name = "Movie"
}
EpisodeType.EPISODE -> {
episode_number = indexNumber?.toFloat() ?: 1F
name = "Ep. $indexNumber - ${this@ItemDto.name}"
}
}
val extraInfo = buildList {
if (epDetails.contains("Overview") && overview != null && epType == EpisodeType.EPISODE) {
add(overview)
}
if (epDetails.contains("Size") && mediaSources != null) {
mediaSources.first().size?.also {
add(it.formatBytes())
}
}
if (epDetails.contains("Runtime") && runTime != null) {
add(runTime.formatTicks())
}
}
scanlator = extraInfo.joinToString("")
premiereData?.also {
date_upload = parseDate(it.removeSuffix("Z"))
}
url = "$baseUrl/Users/$userId/Items/$id?api_key=$apiKey"
}
private fun Long.formatBytes(): String = when {
this >= 1_000_000_000 -> "%.2f GB".format(this / 1_000_000_000.0)
this >= 1_000_000 -> "%.2f MB".format(this / 1_000_000.0)
this >= 1_000 -> "%.2f KB".format(this / 1_000.0)
this > 1 -> "$this bytes"
this == 1L -> "$this byte"
else -> ""
}
private fun Long.formatTicks(): String {
val seconds = this / 10_000_000
val minutes = seconds / 60
val hours = minutes / 60
val remainingSeconds = seconds % 60
val remainingMinutes = minutes % 60
val formattedHours = if (hours > 0) "${hours}h " else ""
val formattedMinutes = if (remainingMinutes > 0) "${remainingMinutes}m " else ""
val formattedSeconds = "${remainingSeconds}s"
return "$formattedHours$formattedMinutes$formattedSeconds".trim()
}
private fun parseDate(dateStr: String): Long {
return runCatching { DATE_FORMATTER.parse(dateStr)?.time }
.getOrNull() ?: 0L
}
companion object {
private val DATE_FORMATTER by lazy {
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSSSSS", Locale.ENGLISH)
}
}
}
@Serializable
data class SessionDto(
@SerialName("MediaSources") val mediaSources: List<MediaDto>,
@SerialName("PlaySessionId") val playSessionId: String,
)
@Serializable
data class MediaDto(
@SerialName("Size") val size: Long? = null,
@SerialName("MediaStreams") val mediaStreams: List<MediaStreamDto>,
) {
@Serializable
data class MediaStreamDto(
@SerialName("Codec") val codec: String,
@SerialName("Index") val index: Int,
@SerialName("Type") val type: String,
@SerialName("SupportsExternalStream") val supportsExternalStream: Boolean,
@SerialName("IsExternal") val isExternal: Boolean,
@SerialName("Language") val lang: String? = null,
@SerialName("DisplayTitle") val displayTitle: String? = null,
@SerialName("Height") val height: Int? = null,
@SerialName("Width") val width: Int? = null,
)
}