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:
@ -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'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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,
|
||||||
|
)
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user