From c976b048e51f8a2e09f6db1a9e3cf174025df37d Mon Sep 17 00:00:00 2001 From: Secozzi <49240133+Secozzi@users.noreply.github.com> Date: Sun, 21 Jan 2024 18:00:57 +0000 Subject: [PATCH] refactor(all/jellyfin): Refactor stuff (#2804) --- src/all/jellyfin/build.gradle | 2 +- .../animeextension/all/jellyfin/DataModel.kt | 78 --- .../animeextension/all/jellyfin/Jellyfin.kt | 621 ++++++++---------- .../all/jellyfin/JellyfinAuthenticator.kt | 12 +- .../{JFConstants.kt => JellyfinConstants.kt} | 47 +- .../all/jellyfin/JellyfinDto.kt | 219 ++++++ 6 files changed, 494 insertions(+), 485 deletions(-) delete mode 100644 src/all/jellyfin/src/eu/kanade/tachiyomi/animeextension/all/jellyfin/DataModel.kt rename src/all/jellyfin/src/eu/kanade/tachiyomi/animeextension/all/jellyfin/{JFConstants.kt => JellyfinConstants.kt} (91%) create mode 100644 src/all/jellyfin/src/eu/kanade/tachiyomi/animeextension/all/jellyfin/JellyfinDto.kt diff --git a/src/all/jellyfin/build.gradle b/src/all/jellyfin/build.gradle index 240ad71e7..6218ff2f6 100644 --- a/src/all/jellyfin/build.gradle +++ b/src/all/jellyfin/build.gradle @@ -1,7 +1,7 @@ ext { extName = 'Jellyfin' extClass = '.JellyfinFactory' - extVersionCode = 11 + extVersionCode = 12 } apply from: "$rootDir/common.gradle" diff --git a/src/all/jellyfin/src/eu/kanade/tachiyomi/animeextension/all/jellyfin/DataModel.kt b/src/all/jellyfin/src/eu/kanade/tachiyomi/animeextension/all/jellyfin/DataModel.kt deleted file mode 100644 index ea6874310..000000000 --- a/src/all/jellyfin/src/eu/kanade/tachiyomi/animeextension/all/jellyfin/DataModel.kt +++ /dev/null @@ -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, -) { - @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? = null, - val Status: String? = null, - val Studios: List? = 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, - val PlaySessionId: String, -) { - @Serializable - data class MediaObject( - val MediaStreams: List, - ) { - @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, - ) -} diff --git a/src/all/jellyfin/src/eu/kanade/tachiyomi/animeextension/all/jellyfin/Jellyfin.kt b/src/all/jellyfin/src/eu/kanade/tachiyomi/animeextension/all/jellyfin/Jellyfin.kt index 965854591..184f8579c 100644 --- a/src/all/jellyfin/src/eu/kanade/tachiyomi/animeextension/all/jellyfin/Jellyfin.kt +++ b/src/all/jellyfin/src/eu/kanade/tachiyomi/animeextension/all/jellyfin/Jellyfin.kt @@ -8,6 +8,7 @@ import android.text.InputType import android.widget.Toast import androidx.preference.EditTextPreference import androidx.preference.ListPreference +import androidx.preference.MultiSelectListPreference import androidx.preference.PreferenceScreen import androidx.preference.SwitchPreferenceCompat 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.online.AnimeHttpSource import eu.kanade.tachiyomi.network.GET -import eu.kanade.tachiyomi.network.awaitSuccess import eu.kanade.tachiyomi.util.parseAs import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json import okhttp3.Dns import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response -import org.jsoup.Jsoup import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get -import uy.kohesive.injekt.injectLazy import java.security.cert.X509Certificate import javax.net.ssl.SSLContext import javax.net.ssl.TrustManager import javax.net.ssl.X509TrustManager -import kotlin.math.ceil -import kotlin.math.floor class Jellyfin(private val suffix: String) : ConfigurableAnimeSource, AnimeHttpSource() { @@ -50,8 +44,6 @@ class Jellyfin(private val suffix: String) : ConfigurableAnimeSource, AnimeHttpS override val supportsLatest = true - private val json: Json by injectLazy() - private fun getUnsafeOkHttpClient(): OkHttpClient { // Create a trust manager that does not validate certificate chains val trustAllCerts = arrayOf( @@ -81,7 +73,7 @@ class Jellyfin(private val suffix: String) : ConfigurableAnimeSource, AnimeHttpS } override val client by lazy { - if (preferences.getBoolean("preferred_trust_all_certs", false)) { + if (preferences.getTrustCert) { getUnsafeOkHttpClient() } else { network.client @@ -94,13 +86,13 @@ class Jellyfin(private val suffix: String) : ConfigurableAnimeSource, AnimeHttpS Injekt.get().getSharedPreferences("source_$id", 0x0000) } - override var baseUrl = JFConstants.getPrefHostUrl(preferences) + override var baseUrl = preferences.getHostUrl - private var username = JFConstants.getPrefUsername(preferences) - private var password = JFConstants.getPrefPassword(preferences) - private var parentId = JFConstants.getPrefParentId(preferences) - private var apiKey = JFConstants.getPrefApiKey(preferences) - private var userId = JFConstants.getPrefUserId(preferences) + private var username = preferences.getUserName + private var password = preferences.getPassword + private var parentId = preferences.getMediaLibId + private var apiKey = preferences.getApiKey + private var userId = preferences.getUserId init { login(false) @@ -108,9 +100,9 @@ class Jellyfin(private val suffix: String) : ConfigurableAnimeSource, AnimeHttpS private fun login(new: Boolean, context: Context? = null): Boolean? { if (apiKey == null || userId == null || new) { - baseUrl = JFConstants.getPrefHostUrl(preferences) - username = JFConstants.getPrefUsername(preferences) - password = JFConstants.getPrefPassword(preferences) + baseUrl = preferences.getHostUrl + username = preferences.getUserName + password = preferences.getPassword if (username.isEmpty() || password.isEmpty()) { if (username != "demo") return null } @@ -133,314 +125,230 @@ class Jellyfin(private val suffix: String) : ConfigurableAnimeSource, AnimeHttpS // ============================== 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 { 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 { addQueryParameter("api_key", apiKey) addQueryParameter("StartIndex", startIndex.toString()) - addQueryParameter("Limit", "20") + addQueryParameter("Limit", SEASONS_LIMIT.toString()) addQueryParameter("Recursive", "true") addQueryParameter("SortBy", "SortName") addQueryParameter("SortOrder", "Ascending") - addQueryParameter("includeItemTypes", "Movie,Season,BoxSet") + addQueryParameter("IncludeItemTypes", "Movie,Season") addQueryParameter("ImageTypeLimit", "1") addQueryParameter("ParentId", parentId) addQueryParameter("EnableImageTypes", "Primary") - } + }.build() - return GET(url.toString()) + return GET(url) } - private fun popularAnimeParsePage(response: Response, page: Int): AnimesPage { - val (list, hasNext) = animeParse(response, page) - return AnimesPage( - list.sortedBy { it.title }, - hasNext, - ) + override fun popularAnimeParse(response: Response): AnimesPage { + val page = response.request.url.queryParameter("StartIndex")!!.toInt() / SEASONS_LIMIT + 1 + val data = response.parseAs() + val animeList = data.items.map { it.toSAnime(baseUrl, userId!!, apiKey!!) } + return AnimesPage(animeList, SEASONS_LIMIT * page < data.itemCount) } // =============================== 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 { - require(parentId.isNotEmpty()) { "Select library in the extension settings." } - val startIndex = (page - 1) * 20 + val url = popularAnimeRequest(page).url.newBuilder().apply { + setQueryParameter("SortBy", "DateCreated,SortName") + setQueryParameter("SortOrder", "Descending") + }.build() - val url = "$baseUrl/Users/$userId/Items".toHttpUrl().newBuilder().apply { - 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()) + return GET(url) } - private fun latestUpdatesParsePage(response: Response, page: Int) = animeParse(response, page) + override fun latestUpdatesParse(response: Response): AnimesPage = + popularAnimeParse(response) // =============================== 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() - - 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() - - 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) + return GET(url) } - private fun getAnimeFromMovie(movieList: List): List { - return movieList.map { - SAnime.create().apply { - 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() - } - } - } + override fun searchAnimeParse(response: Response): AnimesPage { + val page = response.request.url.queryParameter("StartIndex")!!.toInt() / SERIES_LIMIT + 1 + val data = response.parseAs() - private fun getAnimeFromId(id: String): List { - 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("EnableImageTypes", "Primary") - addQueryParameter("ParentId", id) + // Get all seasons from series + val animeList = data.items.flatMap { series -> + val seasonsUrl = popularAnimeRequest(1).url.newBuilder().apply { + setQueryParameter("ParentId", series.id) + removeAllQueryParameters("StartIndex") + removeAllQueryParameters("Limit") + }.build() + + val seasonsData = client.newCall( + GET(seasonsUrl), + ).execute().parseAs() + + seasonsData.items.map { it.toSAnime(baseUrl, userId!!, apiKey!!) } } - val response = client.newCall( - GET(url.build().toString()), - ).execute() - return animeParse(response, 0).animes + return AnimesPage(animeList, SERIES_LIMIT * page < data.itemCount) } // =========================== Anime Details ============================ override fun animeDetailsRequest(anime: SAnime): Request { - val mediaId = json.decodeFromString(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()) + if (!anime.url.startsWith("http")) throw Exception("Migrate from jellyfin to jellyfin") + return GET(anime.url) } override fun animeDetailsParse(response: Response): SAnime { - val info = response.parseAs() + val data = response.parseAs() + 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() - - if (info.Genres != null) anime.genre = info.Genres.joinToString(", ") - - 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("
\n", "br2n") - .replace("
", "br2n") - .replace("\n", "br2n"), - ).text().replace("br2n", "\n") + client.newCall( + GET(url), + ).execute().parseAs() } else { - "" + data } - if (info.Type == "Movie") { - anime.status = SAnime.COMPLETED - } - - anime.title = if (info.SeriesName == null) { - info.Name - } else { - "${info.SeriesName} ${info.Name}" - } - - return anime + return infoData.toSAnime(baseUrl, userId!!, apiKey!!) } // ============================== Episodes ============================== override fun episodeListRequest(anime: SAnime): Request { - val mediaId = json.decodeFromString(anime.url) - return GET(baseUrl + mediaId.path, headers = headers) + if (!anime.url.startsWith("http")) throw Exception("Migrate from jellyfin to jellyfin") + 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 { + val epDetails = preferences.getEpDetails + val episodeList = if (response.request.url.toString().startsWith("$baseUrl/Users/")) { - val parsed = json.decodeFromString(response.body.string()) - listOf( - SEpisode.create().apply { - setUrlWithoutDomain(response.request.url.toString()) - name = "Movie ${parsed.Name}" - episode_number = 1.0F - }, - ) + val data = response.parseAs() + listOf(data.toSEpisode(baseUrl, userId!!, apiKey!!, epDetails, EpisodeType.MOVIE)) } else { - val parsed = response.parseAs() - - parsed.Items.map { ep -> - - 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" - } + val data = response.parseAs() + data.items.map { + it.toSEpisode(baseUrl, userId!!, apiKey!!, epDetails, EpisodeType.EPISODE) } } return episodeList.reversed() } + enum class EpisodeType { + EPISODE, + MOVIE, + } + // ============================ 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