Jellyfin: Rewrite, add support for collection, and preference for metadata grabber (#1212)

* Rewrite, add support for collection, and preference for metadata grabber

* Refractor
This commit is contained in:
Secozzi
2023-01-26 09:58:50 +01:00
committed by GitHub
parent c75ae69fd2
commit 015095eaf4
3 changed files with 355 additions and 278 deletions

View File

@ -1,11 +1,12 @@
apply plugin: 'com.android.application' apply plugin: 'com.android.application'
apply plugin: 'kotlin-android' apply plugin: 'kotlin-android'
apply plugin: 'kotlinx-serialization'
ext { ext {
extName = 'Jellyfin' extName = 'Jellyfin'
pkgNameSuffix = 'all.jellyfin' pkgNameSuffix = 'all.jellyfin'
extClass = '.Jellyfin' extClass = '.Jellyfin'
extVersionCode = 4 extVersionCode = 5
libVersion = '13' libVersion = '13'
} }

View File

@ -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<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 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<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 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,
)

View File

@ -8,6 +8,7 @@ import android.widget.Toast
import androidx.preference.EditTextPreference import androidx.preference.EditTextPreference
import androidx.preference.ListPreference import androidx.preference.ListPreference
import androidx.preference.PreferenceScreen import androidx.preference.PreferenceScreen
import androidx.preference.SwitchPreferenceCompat
import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
import eu.kanade.tachiyomi.animesource.model.AnimesPage 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.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.asObservableSuccess
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.decodeFromString import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json 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.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 org.jsoup.Jsoup
import rx.Observable
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 kotlin.math.ceil import kotlin.math.ceil
import kotlin.math.floor import kotlin.math.floor
@ -48,6 +46,8 @@ class Jellyfin : ConfigurableAnimeSource, AnimeHttpSource() {
override val supportsLatest = true override val supportsLatest = true
private val json: Json by injectLazy()
override val client: OkHttpClient = override val client: OkHttpClient =
network.client network.client
.newBuilder() .newBuilder()
@ -95,7 +95,17 @@ class Jellyfin : ConfigurableAnimeSource, AnimeHttpSource() {
return true 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<AnimesPage> {
return client.newCall(popularAnimeRequest(page))
.asObservableSuccess()
.map { response ->
popularAnimeParsePage(response, page)
}
}
override fun popularAnimeRequest(page: Int): Request { override fun popularAnimeRequest(page: Int): Request {
if (parentId.isEmpty()) { if (parentId.isEmpty()) {
@ -111,7 +121,7 @@ class Jellyfin : ConfigurableAnimeSource, AnimeHttpSource() {
url.addQueryParameter("Recursive", "true") url.addQueryParameter("Recursive", "true")
url.addQueryParameter("SortBy", "SortName") url.addQueryParameter("SortBy", "SortName")
url.addQueryParameter("SortOrder", "Ascending") url.addQueryParameter("SortOrder", "Ascending")
url.addQueryParameter("includeItemTypes", "Movie,Series,Season") url.addQueryParameter("includeItemTypes", "Movie,Series,Season,BoxSet")
url.addQueryParameter("ImageTypeLimit", "1") url.addQueryParameter("ImageTypeLimit", "1")
url.addQueryParameter("ParentId", parentId) url.addQueryParameter("ParentId", parentId)
url.addQueryParameter("EnableImageTypes", "Primary") url.addQueryParameter("EnableImageTypes", "Primary")
@ -119,130 +129,205 @@ class Jellyfin : ConfigurableAnimeSource, AnimeHttpSource() {
return GET(url.toString()) return GET(url.toString())
} }
override fun popularAnimeParse(response: Response): AnimesPage { private fun popularAnimeParsePage(response: Response, page: Int): AnimesPage {
val (list, hasNext) = animeParse(response) val (list, hasNext) = animeParse(response, page)
return AnimesPage( return AnimesPage(
list.sortedBy { it.title }, list.sortedBy { it.title },
hasNext, hasNext,
) )
} }
// Episodes // =============================== Latest ===============================
override fun latestUpdatesParse(response: Response) = throw Exception("Not used")
override fun fetchLatestUpdates(page: Int): Observable<AnimesPage> {
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<AnimesPage> {
if (parentId.isEmpty()) {
throw Exception("Select library in the extension settings.")
}
val animeList = mutableListOf<SAnime>()
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<ItemsResponse>(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<SAnime> {
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<LinkData>(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<ItemsResponse.Item>(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("<br>\n", "br2n")
.replace("<br>", "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<LinkData>(anime.url)
return GET(baseUrl + mediaId.path, headers = headers)
}
override fun episodeListParse(response: Response): List<SEpisode> { override fun episodeListParse(response: Response): List<SEpisode> {
val json = Json.decodeFromString<JsonObject>(response.body!!.string()) val episodeList = if (response.request.url.toString().startsWith("$baseUrl/Users/")) {
val parsed = json.decodeFromString<ItemsResponse.Item>(response.body!!.string())
val episodeList = mutableListOf<SEpisode>()
// Is movie
if (json.containsKey("Type")) {
val episode = SEpisode.create() val episode = SEpisode.create()
val id = json["Id"]!!.jsonPrimitive.content
episode.episode_number = 1.0F episode.episode_number = 1.0F
episode.name = "Movie: " + json["Name"]!!.jsonPrimitive.content episode.name = "Movie ${parsed.Name}"
episode.setUrlWithoutDomain(response.request.url.toString().substringAfter(baseUrl))
episode.setUrlWithoutDomain("/Users/$userId/Items/$id?api_key=$apiKey") listOf(episode)
episodeList.add(episode)
} else { } else {
val items = json["Items"]!!.jsonArray val parsed = json.decodeFromString<ItemsResponse>(response.body!!.string())
for (item in items) { parsed.Items.map { ep ->
val episode = SEpisode.create() val namePrefix = if (ep.IndexNumber == null) {
val jsonObj = item.jsonObject ""
val id = jsonObj["Id"]!!.jsonPrimitive.content
val epNum = if (jsonObj["IndexNumber"] == null) {
null
} else { } else {
jsonObj["IndexNumber"]!!.jsonPrimitive.float val formattedEpNum = if (floor(ep.IndexNumber) == ceil(ep.IndexNumber)) {
} ep.IndexNumber.toInt()
if (epNum != null) {
episode.episode_number = epNum
val formattedEpNum = if (floor(epNum) == ceil(epNum)) {
epNum.toInt().toString()
} else { } else {
epNum.toString() ep.IndexNumber.toFloat()
} }
episode.name = "Episode $formattedEpNum: " + jsonObj["Name"]!!.jsonPrimitive.content "Episode $formattedEpNum "
} else {
episode.episode_number = 0F
episode.name = jsonObj["Name"]!!.jsonPrimitive.content
} }
episode.setUrlWithoutDomain("/Users/$userId/Items/$id?api_key=$apiKey") SEpisode.create().apply {
episodeList.add(episode) name = "$namePrefix${ep.Name}"
episode_number = ep.IndexNumber ?: 0F
url = "/Users/$userId/Items/${ep.Id}?api_key=$apiKey"
}
} }
} }
return episodeList.reversed() return episodeList.reversed()
} }
private fun animeParse(response: Response): AnimesPage { // ============================ Video Links =============================
val items = Json.decodeFromString<JsonObject>(response.body!!.string())["Items"]?.jsonArray
val animesList = mutableListOf<SAnime>()
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
override fun videoListParse(response: Response): List<Video> { override fun videoListParse(response: Response): List<Video> {
val videoList = mutableListOf<Video>() val videoList = mutableListOf<Video>()
val item = Json.decodeFromString<JsonObject>(response.body!!.string()) val id = json.decodeFromString<ItemsResponse.Item>(response.body!!.string()).Id
val id = item["Id"]!!.jsonPrimitive.content
val sessionResponse = client.newCall( val sessionResponse = client.newCall(
GET( GET("$baseUrl/Items/$id/PlaybackInfo?userId=$userId&api_key=$apiKey")
"$baseUrl/Items/$id/PlaybackInfo?userId=$userId&api_key=$apiKey"
)
).execute() ).execute()
val sessionJson = Json.decodeFromString<JsonObject>(sessionResponse.body!!.string()) val parsed = json.decodeFromString<SessionResponse>(sessionResponse.body!!.string())
val sessionId = sessionJson["PlaySessionId"]!!.jsonPrimitive.content
val mediaStreams = sessionJson["MediaSources"]!!.jsonArray[0].jsonObject["MediaStreams"]?.jsonArray
val subtitleList = mutableListOf<Track>() val subtitleList = mutableListOf<Track>()
@ -254,61 +339,48 @@ class Jellyfin : ConfigurableAnimeSource, AnimeHttpSource() {
var width = 1920 var width = 1920
var height = 1080 var height = 1080
// Get subtitle streams and audio index parsed.MediaSources.first().MediaStreams.forEach { media ->
if (mediaStreams != null) { when (media.Type) {
for (media in mediaStreams) { "Subtitle" -> {
val index = media.jsonObject["Index"]!!.jsonPrimitive.int if (media.SupportsExternalStream) {
val codec = media.jsonObject["Codec"]!!.jsonPrimitive.content val subUrl = "$baseUrl/Videos/$id/$id/Subtitles/${media.Index}/0/Stream.${media.Codec}?api_key=$apiKey"
val lang = media.jsonObject["Language"] if (media.Language != null) {
val supportsExternalStream = media.jsonObject["SupportsExternalStream"]!!.jsonPrimitive.boolean if (media.Language == prefSub) {
try {
val type = media.jsonObject["Type"]!!.jsonPrimitive.content subtitleList.add(0, Track(subUrl, media.DisplayTitle!!))
if (type == "Subtitle" && supportsExternalStream) { } catch (e: Error) {
val subUrl = "$baseUrl/Videos/$id/$id/Subtitles/$index/0/Stream.$codec?api_key=$apiKey" subIndex = media.Index
// TODO: add ttf files in media attachment (if possible) }
val title = media.jsonObject["DisplayTitle"]!!.jsonPrimitive.content } else {
if (lang != null) { try {
if (lang.jsonPrimitive.content == prefSub) { subtitleList.add(Track(subUrl, media.DisplayTitle!!))
try { } catch (_: Error) {}
subtitleList.add(0, Track(subUrl, title))
} catch (e: Error) {
subIndex = index
} }
} else { } else {
try { try {
subtitleList.add(Track(subUrl, title)) subtitleList.add(Track(subUrl, media.DisplayTitle!!))
} catch (_: Error) {} } catch (_: Error) {}
} }
} else { } else {
try { if (media.Language != null && media.Language == prefSub) {
subtitleList.add(Track(subUrl, title)) subIndex = media.Index
} catch (_: Error) {}
}
} else if (type == "Subtitle") {
if (lang != null) {
if (lang.jsonPrimitive.content == prefSub) {
subIndex = index
} }
} }
} }
"Audio" -> {
if (type == "Audio") { if (media.Language != null && media.Language == prefAudio) {
if (lang != null) { audioIndex = media.Index
if (lang.jsonPrimitive.content == prefAudio) {
audioIndex = index
}
} }
} }
"Video" -> {
if (media.jsonObject["Type"]!!.jsonPrimitive.content == "Video") { width = media.Width!!
width = media.jsonObject["Width"]!!.jsonPrimitive.int height = media.Height!!
height = media.jsonObject["Height"]!!.jsonPrimitive.int
} }
} }
} }
// Loop over qualities // Loop over qualities
for (quality in JFConstants.QUALITIES_LIST) { JFConstants.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, "Best", url)) videoList.add(Video(url, "Best", url))
@ -330,7 +402,7 @@ class Jellyfin : ConfigurableAnimeSource, AnimeHttpSource() {
url.addQueryParameter( url.addQueryParameter(
"AudioBitrate", quality.audioBitrate.toString() "AudioBitrate", quality.audioBitrate.toString()
) )
url.addQueryParameter("PlaySessionId", sessionId) url.addQueryParameter("PlaySessionId", parsed.PlaySessionId)
url.addQueryParameter("TranscodingMaxAudioChannels", "6") url.addQueryParameter("TranscodingMaxAudioChannels", "6")
url.addQueryParameter("RequireAvc", "false") url.addQueryParameter("RequireAvc", "false")
url.addQueryParameter("SegmentContainer", "ts") url.addQueryParameter("SegmentContainer", "ts")
@ -355,150 +427,81 @@ class Jellyfin : ConfigurableAnimeSource, AnimeHttpSource() {
return videoList.reversed() return videoList.reversed()
} }
// search // ============================= Utilities ==============================
override fun searchAnimeParse(response: Response) = animeParse(response) private fun animeParse(response: Response, page: Int): AnimesPage {
val items = json.decodeFromString<ItemsResponse>(response.body!!.string())
val animesList = mutableListOf<SAnime>()
override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request { items.Items.forEach { item ->
if (parentId.isEmpty()) { val anime = SAnime.create()
throw Exception("Select library in the extension settings.")
}
if (query.isBlank()) {
throw Exception("Search query blank")
}
val startIndex = (page - 1) * 20
val url = "$baseUrl/Users/$userId/Items".toHttpUrl().newBuilder() when (item.Type) {
"Season" -> {
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"
}
url.addQueryParameter("api_key", apiKey) // If season doesn't have image, fallback to series image
url.addQueryParameter("StartIndex", startIndex.toString()) if (item.ImageTags.Primary == null) {
url.addQueryParameter("Limit", "20") anime.thumbnail_url = "$baseUrl/Items/${item.SeriesId}/Images/Primary?api_key=$apiKey"
url.addQueryParameter("Recursive", "true") }
url.addQueryParameter("SortBy", "SortName") animesList.add(anime)
url.addQueryParameter("SortOrder", "Ascending") }
url.addQueryParameter("includeItemTypes", "Movie,Series,Season") "Movie" -> {
url.addQueryParameter("ImageTypeLimit", "1") anime.title = item.Name
url.addQueryParameter("EnableImageTypes", "Primary") anime.thumbnail_url = "$baseUrl/Items/${item.Id}/Images/Primary?api_key=$apiKey"
url.addQueryParameter("ParentId", parentId) anime.setUrlWithoutDomain(
url.addQueryParameter("SearchTerm", query) LinkData(
"/Users/$userId/Items/${item.Id}?api_key=$apiKey",
item.Id,
item.Id
).toJsonString()
)
animesList.add(anime)
}
"BoxSet" -> {
val url = "$baseUrl/Users/$userId/Items".toHttpUrl().newBuilder()
return GET(url.toString()) 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("ParentId", item.Id)
url.addQueryParameter("EnableImageTypes", "Primary")
// Details val response = client.newCall(
GET(url.build().toString(), headers = headers)
override fun animeDetailsRequest(anime: SAnime): Request { ).execute()
val infoArr = anime.url.split("/").toTypedArray() animesList.addAll(animeParse(response, page).animes)
}
val id = if (infoArr[1] == "Users") { else -> {
infoArr[4].split("?").toTypedArray()[0] return@forEach
} else { }
infoArr[2]
}
val url = "$baseUrl/Users/$userId/Items/$id".toHttpUrl().newBuilder()
url.addQueryParameter("api_key", apiKey)
url.addQueryParameter("fields", "Studios")
return GET(url.toString())
}
override fun animeDetailsParse(response: Response): SAnime {
val item = Json.decodeFromString<JsonObject>(response.body!!.string())
val anime = SAnime.create()
anime.author = if (item["Studios"]!!.jsonArray.isEmpty()) {
""
} else {
item["Studios"]!!.jsonArray[0].jsonObject["Name"]!!.jsonPrimitive.content
}
anime.description = item["Overview"]?.let {
Jsoup.parse(it.jsonPrimitive.content.replace("<br>", "br2n")).text().replace("br2n", "\n")
} ?: ""
if (item["Genres"]!!.jsonArray.isEmpty()) {
anime.genre = ""
} else {
val genres = mutableListOf<String>()
for (genre in item["Genres"]!!.jsonArray) {
genres.add(genre.jsonPrimitive.content)
} }
anime.genre = genres.joinToString()
} }
anime.status = item["Status"]?.let { return AnimesPage(animesList, 20 * page < items.TotalRecordCount)
if (it.jsonPrimitive.content == "Ended") SAnime.COMPLETED else SAnime.UNKNOWN
} ?: SAnime.UNKNOWN
if (item["Type"]!!.jsonPrimitive.content == "Season") {
val seasonId = item["Id"]!!.jsonPrimitive.content
val seriesId = item["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 (item["LocationType"]!!.jsonPrimitive.content == "Virtual") {
anime.title = item["SeriesName"]!!.jsonPrimitive.content
anime.thumbnail_url = "$baseUrl/Items/$seriesId/Images/Primary?api_key=$apiKey"
} else {
anime.title = item["SeriesName"]!!.jsonPrimitive.content + " " + item["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 (item["ImageTags"].toString() == "{}") {
anime.thumbnail_url = "$baseUrl/Items/$seriesId/Images/Primary?api_key=$apiKey"
}
} else if (item["Type"]!!.jsonPrimitive.content == "Movie" || item["Type"]!!.jsonPrimitive.content == "Series") {
val id = item["Id"]!!.jsonPrimitive.content
anime.title = item["Name"]!!.jsonPrimitive.content
anime.thumbnail_url = "$baseUrl/Items/$id/Images/Primary?api_key=$apiKey"
anime.setUrlWithoutDomain("/Users/$userId/Items/$id?api_key=$apiKey")
} else {
anime.title = ""
anime.thumbnail_url = ""
}
return anime
} }
// Latest private fun LinkData.toJsonString(): String {
return json.encodeToString(this)
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")
url.addQueryParameter("ImageTypeLimit", "1")
url.addQueryParameter("ParentId", parentId)
url.addQueryParameter("EnableImageTypes", "Primary")
return GET(url.toString())
} }
override fun latestUpdatesParse(response: Response) = animeParse(response)
// Filters - not used
// settings
override fun setupPreferenceScreen(screen: PreferenceScreen) { override fun setupPreferenceScreen(screen: PreferenceScreen) {
val mediaLibPref = medialibPreference(screen) val mediaLibPref = medialibPreference(screen)
screen.addPreference( screen.addPreference(
@ -549,6 +552,19 @@ class Jellyfin : ConfigurableAnimeSource, AnimeHttpSource() {
} }
} }
screen.addPreference(audioLangPref) screen.addPreference(audioLangPref)
val metaTypePref = SwitchPreferenceCompat(screen.context).apply {
key = "preferred_meta_type"
title = "Retrieve metadata from series"
summary = """Enable this to retrieve metadata from series instead of season when applicable.""".trimMargin()
setDefaultValue(false)
setOnPreferenceChangeListener { _, newValue ->
val new = newValue as Boolean
preferences.edit().putBoolean(key, new).commit()
}
}
screen.addPreference(metaTypePref)
} }
private abstract class MediaLibPreference(context: Context) : ListPreference(context) { private abstract class MediaLibPreference(context: Context) : ListPreference(context) {
@ -569,15 +585,15 @@ class Jellyfin : ConfigurableAnimeSource, AnimeHttpSource() {
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<JsonObject>(it.string()) }?.get("Items")?.jsonArray val mediaJson = mediaLibsResponse.body?.let { json.decodeFromString<ItemsResponse>(it.string()) }?.Items
val entriesArray = mutableListOf<String>() val entriesArray = mutableListOf<String>()
val entriesValueArray = mutableListOf<String>() val entriesValueArray = mutableListOf<String>()
if (mediaJson != null) { if (mediaJson != null) {
for (media in mediaJson) { for (media in mediaJson) {
entriesArray.add(media.jsonObject["Name"]!!.jsonPrimitive.content) entriesArray.add(media.Name)
entriesValueArray.add(media.jsonObject["Id"]!!.jsonPrimitive.content) entriesValueArray.add(media.Id)
} }
} }