diff --git a/src/all/jellyfin/build.gradle b/src/all/jellyfin/build.gradle index db60b9aaf..e883a0678 100644 --- a/src/all/jellyfin/build.gradle +++ b/src/all/jellyfin/build.gradle @@ -1,11 +1,12 @@ apply plugin: 'com.android.application' apply plugin: 'kotlin-android' +apply plugin: 'kotlinx-serialization' ext { extName = 'Jellyfin' pkgNameSuffix = 'all.jellyfin' extClass = '.Jellyfin' - extVersionCode = 4 + extVersionCode = 5 libVersion = '13' } 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 new file mode 100644 index 000000000..b37f57929 --- /dev/null +++ b/src/all/jellyfin/src/eu/kanade/tachiyomi/animeextension/all/jellyfin/DataModel.kt @@ -0,0 +1,60 @@ +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 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 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 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, +) 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 9e038fee7..0121b6443 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.widget.Toast import androidx.preference.EditTextPreference import androidx.preference.ListPreference import androidx.preference.PreferenceScreen +import androidx.preference.SwitchPreferenceCompat import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource import eu.kanade.tachiyomi.animesource.model.AnimeFilterList import eu.kanade.tachiyomi.animesource.model.AnimesPage @@ -17,26 +18,23 @@ 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.asObservableSuccess import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.boolean -import kotlinx.serialization.json.float -import kotlinx.serialization.json.int -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive import okhttp3.Dns import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response import org.jsoup.Jsoup +import rx.Observable import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get +import uy.kohesive.injekt.injectLazy import kotlin.math.ceil import kotlin.math.floor @@ -48,6 +46,8 @@ class Jellyfin : ConfigurableAnimeSource, AnimeHttpSource() { override val supportsLatest = true + private val json: Json by injectLazy() + override val client: OkHttpClient = network.client .newBuilder() @@ -95,7 +95,17 @@ class Jellyfin : ConfigurableAnimeSource, AnimeHttpSource() { return true } - // Popular Anime (is currently sorted by name instead of e.g. ratings) + // ============================== Popular =============================== + + override fun popularAnimeParse(response: Response): AnimesPage = throw Exception("Not used") + + override fun fetchPopularAnime(page: Int): Observable { + return client.newCall(popularAnimeRequest(page)) + .asObservableSuccess() + .map { response -> + popularAnimeParsePage(response, page) + } + } override fun popularAnimeRequest(page: Int): Request { if (parentId.isEmpty()) { @@ -111,7 +121,7 @@ class Jellyfin : ConfigurableAnimeSource, AnimeHttpSource() { url.addQueryParameter("Recursive", "true") url.addQueryParameter("SortBy", "SortName") url.addQueryParameter("SortOrder", "Ascending") - url.addQueryParameter("includeItemTypes", "Movie,Series,Season") + url.addQueryParameter("includeItemTypes", "Movie,Series,Season,BoxSet") url.addQueryParameter("ImageTypeLimit", "1") url.addQueryParameter("ParentId", parentId) url.addQueryParameter("EnableImageTypes", "Primary") @@ -119,130 +129,205 @@ class Jellyfin : ConfigurableAnimeSource, AnimeHttpSource() { return GET(url.toString()) } - override fun popularAnimeParse(response: Response): AnimesPage { - val (list, hasNext) = animeParse(response) + private fun popularAnimeParsePage(response: Response, page: Int): AnimesPage { + val (list, hasNext) = animeParse(response, page) return AnimesPage( list.sortedBy { it.title }, hasNext, ) } - // Episodes + // =============================== Latest =============================== + + override fun latestUpdatesParse(response: Response) = throw Exception("Not used") + + override fun fetchLatestUpdates(page: Int): Observable { + return client.newCall(latestUpdatesRequest(page)) + .asObservableSuccess() + .map { response -> + latestUpdatesParsePage(response, page) + } + } + + override fun latestUpdatesRequest(page: Int): Request { + if (parentId.isEmpty()) { + throw Exception("Select library in the extension settings.") + } + + val startIndex = (page - 1) * 20 + + val url = "$baseUrl/Users/$userId/Items".toHttpUrl().newBuilder() + + url.addQueryParameter("api_key", apiKey) + url.addQueryParameter("StartIndex", startIndex.toString()) + url.addQueryParameter("Limit", "20") + url.addQueryParameter("Recursive", "true") + url.addQueryParameter("SortBy", "DateCreated,SortName") + url.addQueryParameter("SortOrder", "Descending") + url.addQueryParameter("includeItemTypes", "Movie,Series,Season,BoxSet") + url.addQueryParameter("ImageTypeLimit", "1") + url.addQueryParameter("ParentId", parentId) + url.addQueryParameter("EnableImageTypes", "Primary") + + return GET(url.toString()) + } + + private fun latestUpdatesParsePage(response: Response, page: Int) = animeParse(response, page) + + // =============================== Search =============================== + + override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request = throw Exception("Not used") + + override fun searchAnimeParse(response: Response) = throw Exception("Not used") + + override fun fetchSearchAnime(page: Int, query: String, filters: AnimeFilterList): Observable { + if (parentId.isEmpty()) { + throw Exception("Select library in the extension settings.") + } + + val animeList = mutableListOf() + val startIndex = (page - 1) * 5 + + val url = "$baseUrl/Users/$userId/Items".toHttpUrl().newBuilder() + + url.addQueryParameter("api_key", apiKey) + url.addQueryParameter("StartIndex", startIndex.toString()) + url.addQueryParameter("Limit", "5") + url.addQueryParameter("Recursive", "true") + url.addQueryParameter("SortBy", "SortName") + url.addQueryParameter("SortOrder", "Ascending") + url.addQueryParameter("includeItemTypes", "Movie,Series,BoxSet") + url.addQueryParameter("ImageTypeLimit", "1") + url.addQueryParameter("EnableImageTypes", "Primary") + url.addQueryParameter("ParentId", parentId) + url.addQueryParameter("SearchTerm", query) + + val response = client.newCall( + GET(url.build().toString(), headers = headers) + ).execute() + val items = json.decodeFromString(response.body!!.string()) + items.Items.forEach { + animeList.addAll( + getAnimeFromId(it.Id) + ) + } + + return Observable.just(AnimesPage(animeList, 5 * page < items.TotalRecordCount)) + } + + private fun getAnimeFromId(id: String): List { + val url = "$baseUrl/Users/$userId/Items".toHttpUrl().newBuilder() + + url.addQueryParameter("api_key", apiKey) + url.addQueryParameter("Recursive", "true") + url.addQueryParameter("SortBy", "SortName") + url.addQueryParameter("SortOrder", "Ascending") + url.addQueryParameter("includeItemTypes", "Movie,Series,Season") + url.addQueryParameter("ImageTypeLimit", "1") + url.addQueryParameter("EnableImageTypes", "Primary") + url.addQueryParameter("ParentId", id) + + val response = client.newCall( + GET(url.build().toString()) + ).execute() + return animeParse(response, 0).animes + } + + // =========================== 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() + + url.addQueryParameter("api_key", apiKey) + url.addQueryParameter("fields", "Studios") + + return GET(url.toString()) + } + + override fun animeDetailsParse(response: Response): SAnime { + val info = json.decodeFromString(response.body!!.string()) + + val anime = SAnime.create() + + if (info.Genres != null) anime.genre = info.Genres.joinToString(", ") + 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") + } else { + "" + } + + anime.title = info.Name + + return anime + } + + // ============================== Episodes ============================== + + override fun episodeListRequest(anime: SAnime): Request { + val mediaId = json.decodeFromString(anime.url) + return GET(baseUrl + mediaId.path, headers = headers) + } override fun episodeListParse(response: Response): List { - val json = Json.decodeFromString(response.body!!.string()) - - val episodeList = mutableListOf() - - // Is movie - if (json.containsKey("Type")) { + val episodeList = if (response.request.url.toString().startsWith("$baseUrl/Users/")) { + val parsed = json.decodeFromString(response.body!!.string()) val episode = SEpisode.create() - val id = json["Id"]!!.jsonPrimitive.content - episode.episode_number = 1.0F - episode.name = "Movie: " + json["Name"]!!.jsonPrimitive.content - - episode.setUrlWithoutDomain("/Users/$userId/Items/$id?api_key=$apiKey") - episodeList.add(episode) + episode.name = "Movie ${parsed.Name}" + episode.setUrlWithoutDomain(response.request.url.toString().substringAfter(baseUrl)) + listOf(episode) } else { - val items = json["Items"]!!.jsonArray + val parsed = json.decodeFromString(response.body!!.string()) - for (item in items) { + parsed.Items.map { ep -> - val episode = SEpisode.create() - val jsonObj = item.jsonObject - - val id = jsonObj["Id"]!!.jsonPrimitive.content - - val epNum = if (jsonObj["IndexNumber"] == null) { - null + val namePrefix = if (ep.IndexNumber == null) { + "" } else { - jsonObj["IndexNumber"]!!.jsonPrimitive.float - } - if (epNum != null) { - episode.episode_number = epNum - val formattedEpNum = if (floor(epNum) == ceil(epNum)) { - epNum.toInt().toString() + val formattedEpNum = if (floor(ep.IndexNumber) == ceil(ep.IndexNumber)) { + ep.IndexNumber.toInt() } else { - epNum.toString() + ep.IndexNumber.toFloat() } - episode.name = "Episode $formattedEpNum: " + jsonObj["Name"]!!.jsonPrimitive.content - } else { - episode.episode_number = 0F - episode.name = jsonObj["Name"]!!.jsonPrimitive.content + "Episode $formattedEpNum " } - episode.setUrlWithoutDomain("/Users/$userId/Items/$id?api_key=$apiKey") - episodeList.add(episode) + SEpisode.create().apply { + name = "$namePrefix${ep.Name}" + episode_number = ep.IndexNumber ?: 0F + url = "/Users/$userId/Items/${ep.Id}?api_key=$apiKey" + } } } return episodeList.reversed() } - private fun animeParse(response: Response): AnimesPage { - val items = Json.decodeFromString(response.body!!.string())["Items"]?.jsonArray - - val animesList = mutableListOf() - - if (items != null) { - for (item in items) { - val anime = SAnime.create() - val jsonObj = item.jsonObject - - if (jsonObj["Type"]!!.jsonPrimitive.content == "Season") { - val seasonId = jsonObj["Id"]!!.jsonPrimitive.content - val seriesId = jsonObj["SeriesId"]!!.jsonPrimitive.content - - anime.setUrlWithoutDomain("/Shows/$seriesId/Episodes?api_key=$apiKey&SeasonId=$seasonId") - - // Virtual if show doesn't have any sub-folders, i.e. no seasons - if (jsonObj["LocationType"]!!.jsonPrimitive.content == "Virtual") { - anime.title = jsonObj["SeriesName"]!!.jsonPrimitive.content - anime.thumbnail_url = "$baseUrl/Items/$seriesId/Images/Primary?api_key=$apiKey" - } else { - anime.title = jsonObj["SeriesName"]!!.jsonPrimitive.content + " " + jsonObj["Name"]!!.jsonPrimitive.content - anime.thumbnail_url = "$baseUrl/Items/$seasonId/Images/Primary?api_key=$apiKey" - } - - // If season doesn't have image, fallback to series image - if (jsonObj["ImageTags"].toString() == "{}") { - anime.thumbnail_url = "$baseUrl/Items/$seriesId/Images/Primary?api_key=$apiKey" - } - } else if (jsonObj["Type"]!!.jsonPrimitive.content == "Movie") { - val id = jsonObj["Id"]!!.jsonPrimitive.content - - anime.title = jsonObj["Name"]!!.jsonPrimitive.content - anime.thumbnail_url = "$baseUrl/Items/$id/Images/Primary?api_key=$apiKey" - - anime.setUrlWithoutDomain("/Users/$userId/Items/$id?api_key=$apiKey") - } else { - continue - } - - animesList.add(anime) - } - } - - val hasNextPage = (items?.size?.compareTo(20) ?: -1) >= 0 - return AnimesPage(animesList, hasNextPage) - } - - // Video urls + // ============================ Video Links ============================= override fun videoListParse(response: Response): List