refactor(all/jellyfin): Refactor stuff (#2804)
This commit is contained in:
parent
ed55a9a3b0
commit
c976b048e5
@ -1,7 +1,7 @@
|
|||||||
ext {
|
ext {
|
||||||
extName = 'Jellyfin'
|
extName = 'Jellyfin'
|
||||||
extClass = '.JellyfinFactory'
|
extClass = '.JellyfinFactory'
|
||||||
extVersionCode = 11
|
extVersionCode = 12
|
||||||
}
|
}
|
||||||
|
|
||||||
apply from: "$rootDir/common.gradle"
|
apply from: "$rootDir/common.gradle"
|
||||||
|
@ -1,78 +0,0 @@
|
|||||||
package eu.kanade.tachiyomi.animeextension.all.jellyfin
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class ItemsResponse(
|
|
||||||
val TotalRecordCount: Int,
|
|
||||||
val Items: List<Item>,
|
|
||||||
) {
|
|
||||||
@Serializable
|
|
||||||
data class Item(
|
|
||||||
val Name: String,
|
|
||||||
val Id: String,
|
|
||||||
val Type: String,
|
|
||||||
val LocationType: String,
|
|
||||||
val ImageTags: ImageObject,
|
|
||||||
val IndexNumber: Float? = null,
|
|
||||||
val Genres: List<String>? = null,
|
|
||||||
val Status: String? = null,
|
|
||||||
val Studios: List<Studio>? = null,
|
|
||||||
val SeriesStudio: String? = null,
|
|
||||||
val Overview: String? = null,
|
|
||||||
val SeriesName: String? = null,
|
|
||||||
val SeriesId: String? = null,
|
|
||||||
) {
|
|
||||||
@Serializable
|
|
||||||
data class ImageObject(
|
|
||||||
val Primary: String? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class Studio(
|
|
||||||
val Name: String? = null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class SessionResponse(
|
|
||||||
val MediaSources: List<MediaObject>,
|
|
||||||
val PlaySessionId: String,
|
|
||||||
) {
|
|
||||||
@Serializable
|
|
||||||
data class MediaObject(
|
|
||||||
val MediaStreams: List<MediaStream>,
|
|
||||||
) {
|
|
||||||
@Serializable
|
|
||||||
data class MediaStream(
|
|
||||||
val Codec: String,
|
|
||||||
val Index: Int,
|
|
||||||
val Type: String,
|
|
||||||
val SupportsExternalStream: Boolean,
|
|
||||||
val IsExternal: Boolean,
|
|
||||||
val Language: String? = null,
|
|
||||||
val DisplayTitle: String? = null,
|
|
||||||
val Height: Int? = null,
|
|
||||||
val Width: Int? = null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class LinkData(
|
|
||||||
val path: String,
|
|
||||||
val seriesId: String,
|
|
||||||
val seasonId: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class LoginResponse(
|
|
||||||
val AccessToken: String,
|
|
||||||
val SessionInfo: SessionObject,
|
|
||||||
) {
|
|
||||||
@Serializable
|
|
||||||
data class SessionObject(
|
|
||||||
val UserId: String,
|
|
||||||
)
|
|
||||||
}
|
|
@ -8,6 +8,7 @@ import android.text.InputType
|
|||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.preference.EditTextPreference
|
import androidx.preference.EditTextPreference
|
||||||
import androidx.preference.ListPreference
|
import androidx.preference.ListPreference
|
||||||
|
import androidx.preference.MultiSelectListPreference
|
||||||
import androidx.preference.PreferenceScreen
|
import androidx.preference.PreferenceScreen
|
||||||
import androidx.preference.SwitchPreferenceCompat
|
import androidx.preference.SwitchPreferenceCompat
|
||||||
import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource
|
import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource
|
||||||
@ -19,28 +20,21 @@ import eu.kanade.tachiyomi.animesource.model.Track
|
|||||||
import eu.kanade.tachiyomi.animesource.model.Video
|
import eu.kanade.tachiyomi.animesource.model.Video
|
||||||
import eu.kanade.tachiyomi.animesource.online.AnimeHttpSource
|
import eu.kanade.tachiyomi.animesource.online.AnimeHttpSource
|
||||||
import eu.kanade.tachiyomi.network.GET
|
import eu.kanade.tachiyomi.network.GET
|
||||||
import eu.kanade.tachiyomi.network.awaitSuccess
|
|
||||||
import eu.kanade.tachiyomi.util.parseAs
|
import eu.kanade.tachiyomi.util.parseAs
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.encodeToString
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import okhttp3.Dns
|
import okhttp3.Dns
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import org.jsoup.Jsoup
|
|
||||||
import uy.kohesive.injekt.Injekt
|
import uy.kohesive.injekt.Injekt
|
||||||
import uy.kohesive.injekt.api.get
|
import uy.kohesive.injekt.api.get
|
||||||
import uy.kohesive.injekt.injectLazy
|
|
||||||
import java.security.cert.X509Certificate
|
import java.security.cert.X509Certificate
|
||||||
import javax.net.ssl.SSLContext
|
import javax.net.ssl.SSLContext
|
||||||
import javax.net.ssl.TrustManager
|
import javax.net.ssl.TrustManager
|
||||||
import javax.net.ssl.X509TrustManager
|
import javax.net.ssl.X509TrustManager
|
||||||
import kotlin.math.ceil
|
|
||||||
import kotlin.math.floor
|
|
||||||
|
|
||||||
class Jellyfin(private val suffix: String) : ConfigurableAnimeSource, AnimeHttpSource() {
|
class Jellyfin(private val suffix: String) : ConfigurableAnimeSource, AnimeHttpSource() {
|
||||||
|
|
||||||
@ -50,8 +44,6 @@ class Jellyfin(private val suffix: String) : ConfigurableAnimeSource, AnimeHttpS
|
|||||||
|
|
||||||
override val supportsLatest = true
|
override val supportsLatest = true
|
||||||
|
|
||||||
private val json: Json by injectLazy()
|
|
||||||
|
|
||||||
private fun getUnsafeOkHttpClient(): OkHttpClient {
|
private fun getUnsafeOkHttpClient(): OkHttpClient {
|
||||||
// Create a trust manager that does not validate certificate chains
|
// Create a trust manager that does not validate certificate chains
|
||||||
val trustAllCerts = arrayOf<TrustManager>(
|
val trustAllCerts = arrayOf<TrustManager>(
|
||||||
@ -81,7 +73,7 @@ class Jellyfin(private val suffix: String) : ConfigurableAnimeSource, AnimeHttpS
|
|||||||
}
|
}
|
||||||
|
|
||||||
override val client by lazy {
|
override val client by lazy {
|
||||||
if (preferences.getBoolean("preferred_trust_all_certs", false)) {
|
if (preferences.getTrustCert) {
|
||||||
getUnsafeOkHttpClient()
|
getUnsafeOkHttpClient()
|
||||||
} else {
|
} else {
|
||||||
network.client
|
network.client
|
||||||
@ -94,13 +86,13 @@ class Jellyfin(private val suffix: String) : ConfigurableAnimeSource, AnimeHttpS
|
|||||||
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
|
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
|
||||||
}
|
}
|
||||||
|
|
||||||
override var baseUrl = JFConstants.getPrefHostUrl(preferences)
|
override var baseUrl = preferences.getHostUrl
|
||||||
|
|
||||||
private var username = JFConstants.getPrefUsername(preferences)
|
private var username = preferences.getUserName
|
||||||
private var password = JFConstants.getPrefPassword(preferences)
|
private var password = preferences.getPassword
|
||||||
private var parentId = JFConstants.getPrefParentId(preferences)
|
private var parentId = preferences.getMediaLibId
|
||||||
private var apiKey = JFConstants.getPrefApiKey(preferences)
|
private var apiKey = preferences.getApiKey
|
||||||
private var userId = JFConstants.getPrefUserId(preferences)
|
private var userId = preferences.getUserId
|
||||||
|
|
||||||
init {
|
init {
|
||||||
login(false)
|
login(false)
|
||||||
@ -108,9 +100,9 @@ class Jellyfin(private val suffix: String) : ConfigurableAnimeSource, AnimeHttpS
|
|||||||
|
|
||||||
private fun login(new: Boolean, context: Context? = null): Boolean? {
|
private fun login(new: Boolean, context: Context? = null): Boolean? {
|
||||||
if (apiKey == null || userId == null || new) {
|
if (apiKey == null || userId == null || new) {
|
||||||
baseUrl = JFConstants.getPrefHostUrl(preferences)
|
baseUrl = preferences.getHostUrl
|
||||||
username = JFConstants.getPrefUsername(preferences)
|
username = preferences.getUserName
|
||||||
password = JFConstants.getPrefPassword(preferences)
|
password = preferences.getPassword
|
||||||
if (username.isEmpty() || password.isEmpty()) {
|
if (username.isEmpty() || password.isEmpty()) {
|
||||||
if (username != "demo") return null
|
if (username != "demo") return null
|
||||||
}
|
}
|
||||||
@ -133,314 +125,230 @@ class Jellyfin(private val suffix: String) : ConfigurableAnimeSource, AnimeHttpS
|
|||||||
|
|
||||||
// ============================== Popular ===============================
|
// ============================== Popular ===============================
|
||||||
|
|
||||||
override fun popularAnimeParse(response: Response): AnimesPage = throw UnsupportedOperationException()
|
|
||||||
|
|
||||||
override suspend fun getPopularAnime(page: Int): AnimesPage {
|
|
||||||
return client.newCall(popularAnimeRequest(page))
|
|
||||||
.awaitSuccess()
|
|
||||||
.use { response ->
|
|
||||||
popularAnimeParsePage(response, page)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun popularAnimeRequest(page: Int): Request {
|
override fun popularAnimeRequest(page: Int): Request {
|
||||||
require(parentId.isNotEmpty()) { "Select library in the extension settings." }
|
require(parentId.isNotEmpty()) { "Select library in the extension settings." }
|
||||||
val startIndex = (page - 1) * 20
|
val startIndex = (page - 1) * SEASONS_LIMIT
|
||||||
|
|
||||||
val url = "$baseUrl/Users/$userId/Items".toHttpUrl().newBuilder().apply {
|
val url = "$baseUrl/Users/$userId/Items".toHttpUrl().newBuilder().apply {
|
||||||
addQueryParameter("api_key", apiKey)
|
addQueryParameter("api_key", apiKey)
|
||||||
addQueryParameter("StartIndex", startIndex.toString())
|
addQueryParameter("StartIndex", startIndex.toString())
|
||||||
addQueryParameter("Limit", "20")
|
addQueryParameter("Limit", SEASONS_LIMIT.toString())
|
||||||
addQueryParameter("Recursive", "true")
|
addQueryParameter("Recursive", "true")
|
||||||
addQueryParameter("SortBy", "SortName")
|
addQueryParameter("SortBy", "SortName")
|
||||||
addQueryParameter("SortOrder", "Ascending")
|
addQueryParameter("SortOrder", "Ascending")
|
||||||
addQueryParameter("includeItemTypes", "Movie,Season,BoxSet")
|
addQueryParameter("IncludeItemTypes", "Movie,Season")
|
||||||
addQueryParameter("ImageTypeLimit", "1")
|
addQueryParameter("ImageTypeLimit", "1")
|
||||||
addQueryParameter("ParentId", parentId)
|
addQueryParameter("ParentId", parentId)
|
||||||
addQueryParameter("EnableImageTypes", "Primary")
|
addQueryParameter("EnableImageTypes", "Primary")
|
||||||
}
|
}.build()
|
||||||
|
|
||||||
return GET(url.toString())
|
return GET(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun popularAnimeParsePage(response: Response, page: Int): AnimesPage {
|
override fun popularAnimeParse(response: Response): AnimesPage {
|
||||||
val (list, hasNext) = animeParse(response, page)
|
val page = response.request.url.queryParameter("StartIndex")!!.toInt() / SEASONS_LIMIT + 1
|
||||||
return AnimesPage(
|
val data = response.parseAs<ItemsDto>()
|
||||||
list.sortedBy { it.title },
|
val animeList = data.items.map { it.toSAnime(baseUrl, userId!!, apiKey!!) }
|
||||||
hasNext,
|
return AnimesPage(animeList, SEASONS_LIMIT * page < data.itemCount)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================== Latest ===============================
|
// =============================== Latest ===============================
|
||||||
|
|
||||||
override fun latestUpdatesParse(response: Response) = throw UnsupportedOperationException()
|
|
||||||
|
|
||||||
override suspend fun getLatestUpdates(page: Int): AnimesPage {
|
|
||||||
return client.newCall(latestUpdatesRequest(page))
|
|
||||||
.awaitSuccess()
|
|
||||||
.use { response ->
|
|
||||||
latestUpdatesParsePage(response, page)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun latestUpdatesRequest(page: Int): Request {
|
override fun latestUpdatesRequest(page: Int): Request {
|
||||||
require(parentId.isNotEmpty()) { "Select library in the extension settings." }
|
val url = popularAnimeRequest(page).url.newBuilder().apply {
|
||||||
val startIndex = (page - 1) * 20
|
setQueryParameter("SortBy", "DateCreated,SortName")
|
||||||
|
setQueryParameter("SortOrder", "Descending")
|
||||||
|
}.build()
|
||||||
|
|
||||||
val url = "$baseUrl/Users/$userId/Items".toHttpUrl().newBuilder().apply {
|
return GET(url)
|
||||||
addQueryParameter("api_key", apiKey)
|
|
||||||
addQueryParameter("StartIndex", startIndex.toString())
|
|
||||||
addQueryParameter("Limit", "20")
|
|
||||||
addQueryParameter("Recursive", "true")
|
|
||||||
addQueryParameter("SortBy", "DateCreated,SortName")
|
|
||||||
addQueryParameter("SortOrder", "Descending")
|
|
||||||
addQueryParameter("includeItemTypes", "Movie,Season,BoxSet")
|
|
||||||
addQueryParameter("ImageTypeLimit", "1")
|
|
||||||
addQueryParameter("ParentId", parentId)
|
|
||||||
addQueryParameter("EnableImageTypes", "Primary")
|
|
||||||
}
|
|
||||||
|
|
||||||
return GET(url.toString())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun latestUpdatesParsePage(response: Response, page: Int) = animeParse(response, page)
|
override fun latestUpdatesParse(response: Response): AnimesPage =
|
||||||
|
popularAnimeParse(response)
|
||||||
|
|
||||||
// =============================== Search ===============================
|
// =============================== Search ===============================
|
||||||
|
|
||||||
override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request = throw UnsupportedOperationException()
|
override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request {
|
||||||
|
val url = popularAnimeRequest(page).url.newBuilder().apply {
|
||||||
|
// Search for series, rather than seasons, since season names can just be "Season 1"
|
||||||
|
setQueryParameter("IncludeItemTypes", "Movie,Series")
|
||||||
|
setQueryParameter("Limit", SERIES_LIMIT.toString())
|
||||||
|
setQueryParameter("SearchTerm", query)
|
||||||
|
}.build()
|
||||||
|
|
||||||
override fun searchAnimeParse(response: Response) = throw UnsupportedOperationException()
|
return GET(url)
|
||||||
|
|
||||||
override suspend fun getSearchAnime(page: Int, query: String, filters: AnimeFilterList): AnimesPage {
|
|
||||||
require(parentId.isNotEmpty()) { "Select library in the extension settings." }
|
|
||||||
val startIndex = (page - 1) * 5
|
|
||||||
|
|
||||||
val url = "$baseUrl/Users/$userId/Items".toHttpUrl().newBuilder().apply {
|
|
||||||
addQueryParameter("api_key", apiKey)
|
|
||||||
addQueryParameter("StartIndex", startIndex.toString())
|
|
||||||
addQueryParameter("Limit", "5")
|
|
||||||
addQueryParameter("Recursive", "true")
|
|
||||||
addQueryParameter("SortBy", "SortName")
|
|
||||||
addQueryParameter("SortOrder", "Ascending")
|
|
||||||
addQueryParameter("IncludeItemTypes", "Series,Movie,BoxSet")
|
|
||||||
addQueryParameter("ImageTypeLimit", "1")
|
|
||||||
addQueryParameter("EnableImageTypes", "Primary")
|
|
||||||
addQueryParameter("ParentId", parentId)
|
|
||||||
addQueryParameter("SearchTerm", query)
|
|
||||||
}
|
|
||||||
|
|
||||||
val items = client.newCall(
|
|
||||||
GET(url.build().toString(), headers = headers),
|
|
||||||
).execute().parseAs<ItemsResponse>()
|
|
||||||
|
|
||||||
val movieList = items.Items.filter { it.Type == "Movie" }
|
|
||||||
val nonMovieList = items.Items.filter { it.Type != "Movie" }
|
|
||||||
|
|
||||||
val animeList = getAnimeFromMovie(movieList) + nonMovieList.flatMap {
|
|
||||||
getAnimeFromId(it.Id)
|
|
||||||
}
|
|
||||||
|
|
||||||
return AnimesPage(animeList, 5 * page < items.TotalRecordCount)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getAnimeFromMovie(movieList: List<ItemsResponse.Item>): List<SAnime> {
|
override fun searchAnimeParse(response: Response): AnimesPage {
|
||||||
return movieList.map {
|
val page = response.request.url.queryParameter("StartIndex")!!.toInt() / SERIES_LIMIT + 1
|
||||||
SAnime.create().apply {
|
val data = response.parseAs<ItemsDto>()
|
||||||
title = it.Name
|
|
||||||
thumbnail_url = "$baseUrl/Items/${it.Id}/Images/Primary?api_key=$apiKey"
|
|
||||||
url = LinkData(
|
|
||||||
"/Users/$userId/Items/${it.Id}?api_key=$apiKey",
|
|
||||||
it.Id,
|
|
||||||
it.Id,
|
|
||||||
).toJsonString()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getAnimeFromId(id: String): List<SAnime> {
|
// Get all seasons from series
|
||||||
val url = "$baseUrl/Users/$userId/Items".toHttpUrl().newBuilder().apply {
|
val animeList = data.items.flatMap { series ->
|
||||||
addQueryParameter("api_key", apiKey)
|
val seasonsUrl = popularAnimeRequest(1).url.newBuilder().apply {
|
||||||
addQueryParameter("Recursive", "true")
|
setQueryParameter("ParentId", series.id)
|
||||||
addQueryParameter("SortBy", "SortName")
|
removeAllQueryParameters("StartIndex")
|
||||||
addQueryParameter("SortOrder", "Ascending")
|
removeAllQueryParameters("Limit")
|
||||||
addQueryParameter("includeItemTypes", "Movie,Series,Season")
|
}.build()
|
||||||
addQueryParameter("ImageTypeLimit", "1")
|
|
||||||
addQueryParameter("EnableImageTypes", "Primary")
|
val seasonsData = client.newCall(
|
||||||
addQueryParameter("ParentId", id)
|
GET(seasonsUrl),
|
||||||
|
).execute().parseAs<ItemsDto>()
|
||||||
|
|
||||||
|
seasonsData.items.map { it.toSAnime(baseUrl, userId!!, apiKey!!) }
|
||||||
}
|
}
|
||||||
|
|
||||||
val response = client.newCall(
|
return AnimesPage(animeList, SERIES_LIMIT * page < data.itemCount)
|
||||||
GET(url.build().toString()),
|
|
||||||
).execute()
|
|
||||||
return animeParse(response, 0).animes
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================== Anime Details ============================
|
// =========================== Anime Details ============================
|
||||||
|
|
||||||
override fun animeDetailsRequest(anime: SAnime): Request {
|
override fun animeDetailsRequest(anime: SAnime): Request {
|
||||||
val mediaId = json.decodeFromString<LinkData>(anime.url)
|
if (!anime.url.startsWith("http")) throw Exception("Migrate from jellyfin to jellyfin")
|
||||||
|
return GET(anime.url)
|
||||||
val infoId = if (preferences.getBoolean("preferred_meta_type", false)) {
|
|
||||||
mediaId.seriesId
|
|
||||||
} else {
|
|
||||||
mediaId.seasonId
|
|
||||||
}
|
|
||||||
|
|
||||||
val url = "$baseUrl/Users/$userId/Items/$infoId".toHttpUrl().newBuilder().apply {
|
|
||||||
addQueryParameter("api_key", apiKey)
|
|
||||||
addQueryParameter("fields", "Studios")
|
|
||||||
}
|
|
||||||
|
|
||||||
return GET(url.toString())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun animeDetailsParse(response: Response): SAnime {
|
override fun animeDetailsParse(response: Response): SAnime {
|
||||||
val info = response.parseAs<ItemsResponse.Item>()
|
val data = response.parseAs<ItemDto>()
|
||||||
|
val infoData = if (preferences.useSeriesData && data.seriesId != null) {
|
||||||
|
val url = response.request.url.let { url ->
|
||||||
|
url.newBuilder().apply {
|
||||||
|
removePathSegment(url.pathSize - 1)
|
||||||
|
addPathSegment(data.seriesId)
|
||||||
|
}.build()
|
||||||
|
}
|
||||||
|
|
||||||
val anime = SAnime.create()
|
client.newCall(
|
||||||
|
GET(url),
|
||||||
if (info.Genres != null) anime.genre = info.Genres.joinToString(", ")
|
).execute().parseAs<ItemDto>()
|
||||||
|
|
||||||
if (!info.Studios.isNullOrEmpty()) {
|
|
||||||
anime.author = info.Studios.mapNotNull { it.Name }.joinToString(", ")
|
|
||||||
} else if (info.SeriesStudio != null) anime.author = info.SeriesStudio
|
|
||||||
|
|
||||||
anime.description = if (info.Overview != null) {
|
|
||||||
Jsoup.parse(
|
|
||||||
info.Overview
|
|
||||||
.replace("<br>\n", "br2n")
|
|
||||||
.replace("<br>", "br2n")
|
|
||||||
.replace("\n", "br2n"),
|
|
||||||
).text().replace("br2n", "\n")
|
|
||||||
} else {
|
} else {
|
||||||
""
|
data
|
||||||
}
|
}
|
||||||
|
|
||||||
if (info.Type == "Movie") {
|
return infoData.toSAnime(baseUrl, userId!!, apiKey!!)
|
||||||
anime.status = SAnime.COMPLETED
|
|
||||||
}
|
|
||||||
|
|
||||||
anime.title = if (info.SeriesName == null) {
|
|
||||||
info.Name
|
|
||||||
} else {
|
|
||||||
"${info.SeriesName} ${info.Name}"
|
|
||||||
}
|
|
||||||
|
|
||||||
return anime
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================== Episodes ==============================
|
// ============================== Episodes ==============================
|
||||||
|
|
||||||
override fun episodeListRequest(anime: SAnime): Request {
|
override fun episodeListRequest(anime: SAnime): Request {
|
||||||
val mediaId = json.decodeFromString<LinkData>(anime.url)
|
if (!anime.url.startsWith("http")) throw Exception("Migrate from jellyfin to jellyfin")
|
||||||
return GET(baseUrl + mediaId.path, headers = headers)
|
val httpUrl = anime.url.toHttpUrl()
|
||||||
|
val fragment = httpUrl.fragment!!
|
||||||
|
|
||||||
|
// Get episodes of season
|
||||||
|
val url = if (fragment.startsWith("seriesId")) {
|
||||||
|
httpUrl.newBuilder().apply {
|
||||||
|
encodedPath("/")
|
||||||
|
encodedQuery(null)
|
||||||
|
fragment(null)
|
||||||
|
|
||||||
|
addPathSegment("Shows")
|
||||||
|
addPathSegment(fragment.split(",").last())
|
||||||
|
addPathSegment("Episodes")
|
||||||
|
addQueryParameter("api_key", apiKey)
|
||||||
|
addQueryParameter("seasonId", httpUrl.pathSegments.last())
|
||||||
|
addQueryParameter("userId", userId)
|
||||||
|
addQueryParameter("Fields", "Overview,MediaSources")
|
||||||
|
}.build()
|
||||||
|
} else if (fragment.startsWith("movie")) {
|
||||||
|
httpUrl.newBuilder().fragment(null).build()
|
||||||
|
} else {
|
||||||
|
httpUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
return GET(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun episodeListParse(response: Response): List<SEpisode> {
|
override fun episodeListParse(response: Response): List<SEpisode> {
|
||||||
|
val epDetails = preferences.getEpDetails
|
||||||
|
|
||||||
val episodeList = if (response.request.url.toString().startsWith("$baseUrl/Users/")) {
|
val episodeList = if (response.request.url.toString().startsWith("$baseUrl/Users/")) {
|
||||||
val parsed = json.decodeFromString<ItemsResponse.Item>(response.body.string())
|
val data = response.parseAs<ItemDto>()
|
||||||
listOf(
|
listOf(data.toSEpisode(baseUrl, userId!!, apiKey!!, epDetails, EpisodeType.MOVIE))
|
||||||
SEpisode.create().apply {
|
|
||||||
setUrlWithoutDomain(response.request.url.toString())
|
|
||||||
name = "Movie ${parsed.Name}"
|
|
||||||
episode_number = 1.0F
|
|
||||||
},
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
val parsed = response.parseAs<ItemsResponse>()
|
val data = response.parseAs<ItemsDto>()
|
||||||
|
data.items.map {
|
||||||
parsed.Items.map { ep ->
|
it.toSEpisode(baseUrl, userId!!, apiKey!!, epDetails, EpisodeType.EPISODE)
|
||||||
|
|
||||||
val namePrefix = if (ep.IndexNumber == null) {
|
|
||||||
""
|
|
||||||
} else {
|
|
||||||
val formattedEpNum = if (floor(ep.IndexNumber) == ceil(ep.IndexNumber)) {
|
|
||||||
ep.IndexNumber.toInt()
|
|
||||||
} else {
|
|
||||||
ep.IndexNumber.toFloat()
|
|
||||||
}
|
|
||||||
"Episode $formattedEpNum "
|
|
||||||
}
|
|
||||||
|
|
||||||
SEpisode.create().apply {
|
|
||||||
name = "$namePrefix${ep.Name}"
|
|
||||||
episode_number = ep.IndexNumber ?: 0F
|
|
||||||
url = "/Users/$userId/Items/${ep.Id}?api_key=$apiKey"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return episodeList.reversed()
|
return episodeList.reversed()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum class EpisodeType {
|
||||||
|
EPISODE,
|
||||||
|
MOVIE,
|
||||||
|
}
|
||||||
|
|
||||||
// ============================ Video Links =============================
|
// ============================ Video Links =============================
|
||||||
|
|
||||||
|
override fun videoListRequest(episode: SEpisode): Request {
|
||||||
|
if (!episode.url.startsWith("http")) throw Exception("Migrate from jellyfin to jellyfin")
|
||||||
|
return GET(episode.url)
|
||||||
|
}
|
||||||
|
|
||||||
override fun videoListParse(response: Response): List<Video> {
|
override fun videoListParse(response: Response): List<Video> {
|
||||||
val videoList = mutableListOf<Video>()
|
val id = response.parseAs<ItemDto>().id
|
||||||
val id = response.parseAs<ItemsResponse.Item>().Id
|
|
||||||
|
|
||||||
val parsed = client.newCall(
|
val sessionData = client.newCall(
|
||||||
GET("$baseUrl/Items/$id/PlaybackInfo?userId=$userId&api_key=$apiKey"),
|
GET("$baseUrl/Items/$id/PlaybackInfo?userId=$userId&api_key=$apiKey"),
|
||||||
).execute().parseAs<SessionResponse>()
|
).execute().parseAs<SessionDto>()
|
||||||
|
|
||||||
|
val videoList = mutableListOf<Video>()
|
||||||
val subtitleList = mutableListOf<Track>()
|
val subtitleList = mutableListOf<Track>()
|
||||||
val externalSubtitleList = mutableListOf<Track>()
|
val externalSubtitleList = mutableListOf<Track>()
|
||||||
|
|
||||||
val prefSub = preferences.getString(JFConstants.PREF_SUB_KEY, "eng")!!
|
val prefSub = preferences.getSubPref
|
||||||
val prefAudio = preferences.getString(JFConstants.PREF_AUDIO_KEY, "jpn")!!
|
val prefAudio = preferences.getAudioPref
|
||||||
|
|
||||||
var audioIndex = 1
|
var audioIndex = 1
|
||||||
var subIndex: Int? = null
|
var subIndex: Int? = null
|
||||||
var width = 1920
|
var width = 1920
|
||||||
var height = 1080
|
var height = 1080
|
||||||
|
|
||||||
parsed.MediaSources.first().MediaStreams.forEach { media ->
|
sessionData.mediaSources.first().mediaStreams.forEach { media ->
|
||||||
when (media.Type) {
|
when (media.type) {
|
||||||
"Subtitle" -> {
|
"Video" -> {
|
||||||
if (media.SupportsExternalStream) {
|
width = media.width!!
|
||||||
val subUrl = "$baseUrl/Videos/$id/$id/Subtitles/${media.Index}/0/Stream.${media.Codec}?api_key=$apiKey"
|
height = media.height!!
|
||||||
if (media.Language != null) {
|
|
||||||
if (media.Language == prefSub) {
|
|
||||||
try {
|
|
||||||
if (media.IsExternal) {
|
|
||||||
externalSubtitleList.add(0, Track(subUrl, media.DisplayTitle!!))
|
|
||||||
}
|
|
||||||
subtitleList.add(0, Track(subUrl, media.DisplayTitle!!))
|
|
||||||
} catch (e: Error) {
|
|
||||||
subIndex = media.Index
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (media.IsExternal) {
|
|
||||||
externalSubtitleList.add(Track(subUrl, media.DisplayTitle!!))
|
|
||||||
}
|
|
||||||
subtitleList.add(Track(subUrl, media.DisplayTitle!!))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (media.IsExternal) {
|
|
||||||
externalSubtitleList.add(Track(subUrl, media.DisplayTitle!!))
|
|
||||||
}
|
|
||||||
subtitleList.add(Track(subUrl, media.DisplayTitle!!))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (media.Language != null && media.Language == prefSub) {
|
|
||||||
subIndex = media.Index
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
"Audio" -> {
|
"Audio" -> {
|
||||||
if (media.Language != null && media.Language == prefAudio) {
|
if (media.lang != null && media.lang == prefAudio) {
|
||||||
audioIndex = media.Index
|
audioIndex = media.index
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"Video" -> {
|
"Subtitle" -> {
|
||||||
width = media.Width!!
|
if (media.supportsExternalStream) {
|
||||||
height = media.Height!!
|
val subtitleUrl = "$baseUrl/Videos/$id/$id/Subtitles/${media.index}/0/Stream.${media.codec}?api_key=$apiKey"
|
||||||
|
if (media.lang != null) {
|
||||||
|
if (media.lang == prefSub) {
|
||||||
|
try {
|
||||||
|
if (media.isExternal) {
|
||||||
|
externalSubtitleList.add(0, Track(subtitleUrl, media.displayTitle!!))
|
||||||
|
}
|
||||||
|
subtitleList.add(0, Track(subtitleUrl, media.displayTitle!!))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
subIndex = media.index
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (media.isExternal) {
|
||||||
|
externalSubtitleList.add(Track(subtitleUrl, media.displayTitle!!))
|
||||||
|
}
|
||||||
|
subtitleList.add(Track(subtitleUrl, media.displayTitle!!))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (media.isExternal) {
|
||||||
|
externalSubtitleList.add(Track(subtitleUrl, media.displayTitle!!))
|
||||||
|
}
|
||||||
|
subtitleList.add(Track(subtitleUrl, media.displayTitle!!))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Loop over qualities
|
// Loop over qualities
|
||||||
JFConstants.QUALITIES_LIST.forEach { quality ->
|
JellyfinConstants.QUALITIES_LIST.forEach { quality ->
|
||||||
if (width < quality.width && height < quality.height) {
|
if (width < quality.width && height < quality.height) {
|
||||||
val url = "$baseUrl/Videos/$id/stream?static=True&api_key=$apiKey"
|
val url = "$baseUrl/Videos/$id/stream?static=True&api_key=$apiKey"
|
||||||
videoList.add(Video(url, "Source", url, subtitleTracks = externalSubtitleList))
|
videoList.add(Video(url, "Source", url, subtitleTracks = externalSubtitleList))
|
||||||
@ -463,7 +371,7 @@ class Jellyfin(private val suffix: String) : ConfigurableAnimeSource, AnimeHttpS
|
|||||||
"AudioBitrate",
|
"AudioBitrate",
|
||||||
quality.audioBitrate.toString(),
|
quality.audioBitrate.toString(),
|
||||||
)
|
)
|
||||||
addQueryParameter("PlaySessionId", parsed.PlaySessionId)
|
addQueryParameter("PlaySessionId", sessionData.playSessionId)
|
||||||
addQueryParameter("TranscodingMaxAudioChannels", "6")
|
addQueryParameter("TranscodingMaxAudioChannels", "6")
|
||||||
addQueryParameter("RequireAvc", "false")
|
addQueryParameter("RequireAvc", "false")
|
||||||
addQueryParameter("SegmentContainer", "ts")
|
addQueryParameter("SegmentContainer", "ts")
|
||||||
@ -474,7 +382,6 @@ class Jellyfin(private val suffix: String) : ConfigurableAnimeSource, AnimeHttpS
|
|||||||
addQueryParameter("h264-deinterlace", "true")
|
addQueryParameter("h264-deinterlace", "true")
|
||||||
addQueryParameter("TranscodeReasons", "VideoCodecNotSupported,AudioCodecNotSupported,ContainerBitrateExceedsLimit")
|
addQueryParameter("TranscodeReasons", "VideoCodecNotSupported,AudioCodecNotSupported,ContainerBitrateExceedsLimit")
|
||||||
}
|
}
|
||||||
|
|
||||||
videoList.add(Video(url.toString(), quality.description, url.toString(), subtitleTracks = subtitleList))
|
videoList.add(Video(url.toString(), quality.description, url.toString(), subtitleTracks = subtitleList))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -487,83 +394,49 @@ class Jellyfin(private val suffix: String) : ConfigurableAnimeSource, AnimeHttpS
|
|||||||
|
|
||||||
// ============================= Utilities ==============================
|
// ============================= Utilities ==============================
|
||||||
|
|
||||||
private fun animeParse(response: Response, page: Int): AnimesPage {
|
companion object {
|
||||||
val items = response.parseAs<ItemsResponse>()
|
const val APIKEY_KEY = "api_key"
|
||||||
|
const val USERID_KEY = "user_id"
|
||||||
|
|
||||||
val animeList = items.Items.flatMap { item ->
|
private const val HOSTURL_KEY = "host_url"
|
||||||
val anime = SAnime.create()
|
private const val HOSTURL_DEFAULT = "http://127.0.0.1:8096"
|
||||||
|
|
||||||
when (item.Type) {
|
private const val USERNAME_KEY = "username"
|
||||||
"Season" -> {
|
private const val USERNAME_DEFAULT = ""
|
||||||
anime.setUrlWithoutDomain(
|
|
||||||
LinkData(
|
|
||||||
path = "/Shows/${item.SeriesId}/Episodes?SeasonId=${item.Id}&api_key=$apiKey",
|
|
||||||
seriesId = item.SeriesId!!,
|
|
||||||
seasonId = item.Id,
|
|
||||||
).toJsonString(),
|
|
||||||
)
|
|
||||||
// Virtual if show doesn't have any sub-folders, i.e. no seasons
|
|
||||||
if (item.LocationType == "Virtual") {
|
|
||||||
anime.title = item.SeriesName!!
|
|
||||||
anime.thumbnail_url = "$baseUrl/Items/${item.SeriesId}/Images/Primary?api_key=$apiKey"
|
|
||||||
} else {
|
|
||||||
anime.title = "${item.SeriesName} ${item.Name}"
|
|
||||||
anime.thumbnail_url = "$baseUrl/Items/${item.Id}/Images/Primary?api_key=$apiKey"
|
|
||||||
}
|
|
||||||
|
|
||||||
// If season doesn't have image, fallback to series image
|
private const val PASSWORD_KEY = "password"
|
||||||
if (item.ImageTags.Primary == null) {
|
private const val PASSWORD_DEFAULT = ""
|
||||||
anime.thumbnail_url = "$baseUrl/Items/${item.SeriesId}/Images/Primary?api_key=$apiKey"
|
|
||||||
}
|
|
||||||
listOf(anime)
|
|
||||||
}
|
|
||||||
"Movie" -> {
|
|
||||||
anime.title = item.Name
|
|
||||||
anime.thumbnail_url = "$baseUrl/Items/${item.Id}/Images/Primary?api_key=$apiKey"
|
|
||||||
anime.setUrlWithoutDomain(
|
|
||||||
LinkData(
|
|
||||||
"/Users/$userId/Items/${item.Id}?api_key=$apiKey",
|
|
||||||
item.Id,
|
|
||||||
item.Id,
|
|
||||||
).toJsonString(),
|
|
||||||
)
|
|
||||||
listOf(anime)
|
|
||||||
}
|
|
||||||
"BoxSet" -> {
|
|
||||||
val url = "$baseUrl/Users/$userId/Items".toHttpUrl().newBuilder().apply {
|
|
||||||
addQueryParameter("api_key", apiKey)
|
|
||||||
addQueryParameter("Recursive", "true")
|
|
||||||
addQueryParameter("SortBy", "SortName")
|
|
||||||
addQueryParameter("SortOrder", "Ascending")
|
|
||||||
addQueryParameter("includeItemTypes", "Movie,Series,Season")
|
|
||||||
addQueryParameter("ImageTypeLimit", "1")
|
|
||||||
addQueryParameter("ParentId", item.Id)
|
|
||||||
addQueryParameter("EnableImageTypes", "Primary")
|
|
||||||
}
|
|
||||||
|
|
||||||
val response = client.newCall(
|
private const val MEDIALIB_KEY = "library_pref"
|
||||||
GET(url.build().toString(), headers = headers),
|
private const val MEDIALIB_DEFAULT = ""
|
||||||
).execute()
|
|
||||||
animeParse(response, page).animes
|
|
||||||
}
|
|
||||||
else -> emptyList()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return AnimesPage(animeList, 20 * page < items.TotalRecordCount)
|
private const val SEASONS_LIMIT = 20
|
||||||
}
|
private const val SERIES_LIMIT = 5
|
||||||
|
|
||||||
private fun LinkData.toJsonString(): String {
|
private const val PREF_EP_DETAILS_KEY = "pref_episode_details_key"
|
||||||
return json.encodeToString(this)
|
private val PREF_EP_DETAILS = arrayOf("Overview", "Runtime", "Size")
|
||||||
|
private val PREF_EP_DETAILS_DEFAULT = emptySet<String>()
|
||||||
|
|
||||||
|
private const val PREF_SUB_KEY = "preferred_subLang"
|
||||||
|
private const val PREF_SUB_DEFAULT = "eng"
|
||||||
|
|
||||||
|
private const val PREF_AUDIO_KEY = "preferred_audioLang"
|
||||||
|
private const val PREF_AUDIO_DEFAULT = "jpn"
|
||||||
|
|
||||||
|
private const val PREF_INFO_TYPE = "preferred_meta_type"
|
||||||
|
private const val PREF_INFO_DEFAULT = false
|
||||||
|
|
||||||
|
private const val PREF_TRUST_CERT_KEY = "preferred_trust_all_certs"
|
||||||
|
private const val PREF_TRUST_CERT_DEFAULT = false
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||||
val mediaLibPref = medialibPreference(screen)
|
val mediaLibPref = medialibPreference(screen)
|
||||||
screen.addPreference(
|
screen.addPreference(
|
||||||
screen.editTextPreference(
|
screen.editTextPreference(
|
||||||
JFConstants.HOSTURL_KEY,
|
HOSTURL_KEY,
|
||||||
JFConstants.HOSTURL_TITLE,
|
"Host URL",
|
||||||
JFConstants.HOSTURL_DEFAULT,
|
HOSTURL_DEFAULT,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
false,
|
false,
|
||||||
"",
|
"",
|
||||||
@ -572,20 +445,20 @@ class Jellyfin(private val suffix: String) : ConfigurableAnimeSource, AnimeHttpS
|
|||||||
)
|
)
|
||||||
screen.addPreference(
|
screen.addPreference(
|
||||||
screen.editTextPreference(
|
screen.editTextPreference(
|
||||||
JFConstants.USERNAME_KEY,
|
USERNAME_KEY,
|
||||||
JFConstants.USERNAME_TITLE,
|
"Username",
|
||||||
"",
|
USERNAME_DEFAULT,
|
||||||
username,
|
username,
|
||||||
false,
|
false,
|
||||||
"",
|
"The account username",
|
||||||
mediaLibPref,
|
mediaLibPref,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
screen.addPreference(
|
screen.addPreference(
|
||||||
screen.editTextPreference(
|
screen.editTextPreference(
|
||||||
JFConstants.PASSWORD_KEY,
|
PASSWORD_KEY,
|
||||||
JFConstants.PASSWORD_TITLE,
|
"Password",
|
||||||
"",
|
PASSWORD_DEFAULT,
|
||||||
password,
|
password,
|
||||||
true,
|
true,
|
||||||
"••••••••",
|
"••••••••",
|
||||||
@ -593,12 +466,27 @@ class Jellyfin(private val suffix: String) : ConfigurableAnimeSource, AnimeHttpS
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
screen.addPreference(mediaLibPref)
|
screen.addPreference(mediaLibPref)
|
||||||
val subLangPref = ListPreference(screen.context).apply {
|
|
||||||
key = JFConstants.PREF_SUB_KEY
|
MultiSelectListPreference(screen.context).apply {
|
||||||
title = JFConstants.PREF_SUB_TITLE
|
key = PREF_EP_DETAILS_KEY
|
||||||
entries = JFConstants.PREF_ENTRIES
|
title = "Additional details for episodes"
|
||||||
entryValues = JFConstants.PREF_VALUES
|
summary = "Show additional details about an episode in the scanlator field"
|
||||||
setDefaultValue("eng")
|
entries = PREF_EP_DETAILS
|
||||||
|
entryValues = PREF_EP_DETAILS
|
||||||
|
setDefaultValue(PREF_EP_DETAILS_DEFAULT)
|
||||||
|
|
||||||
|
setOnPreferenceChangeListener { _, newValue ->
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
preferences.edit().putStringSet(key, newValue as Set<String>).commit()
|
||||||
|
}
|
||||||
|
}.also(screen::addPreference)
|
||||||
|
|
||||||
|
ListPreference(screen.context).apply {
|
||||||
|
key = PREF_SUB_KEY
|
||||||
|
title = "Preferred sub language"
|
||||||
|
entries = JellyfinConstants.PREF_ENTRIES
|
||||||
|
entryValues = JellyfinConstants.PREF_VALUES
|
||||||
|
setDefaultValue(PREF_SUB_DEFAULT)
|
||||||
summary = "%s"
|
summary = "%s"
|
||||||
|
|
||||||
setOnPreferenceChangeListener { _, newValue ->
|
setOnPreferenceChangeListener { _, newValue ->
|
||||||
@ -607,14 +495,14 @@ class Jellyfin(private val suffix: String) : ConfigurableAnimeSource, AnimeHttpS
|
|||||||
val entry = entryValues[index] as String
|
val entry = entryValues[index] as String
|
||||||
preferences.edit().putString(key, entry).commit()
|
preferences.edit().putString(key, entry).commit()
|
||||||
}
|
}
|
||||||
}
|
}.also(screen::addPreference)
|
||||||
screen.addPreference(subLangPref)
|
|
||||||
val audioLangPref = ListPreference(screen.context).apply {
|
ListPreference(screen.context).apply {
|
||||||
key = JFConstants.PREF_AUDIO_KEY
|
key = PREF_AUDIO_KEY
|
||||||
title = JFConstants.PREF_AUDIO_TITLE
|
title = "Preferred audio language"
|
||||||
entries = JFConstants.PREF_ENTRIES
|
entries = JellyfinConstants.PREF_ENTRIES
|
||||||
entryValues = JFConstants.PREF_VALUES
|
entryValues = JellyfinConstants.PREF_VALUES
|
||||||
setDefaultValue("jpn")
|
setDefaultValue(PREF_AUDIO_DEFAULT)
|
||||||
summary = "%s"
|
summary = "%s"
|
||||||
|
|
||||||
setOnPreferenceChangeListener { _, newValue ->
|
setOnPreferenceChangeListener { _, newValue ->
|
||||||
@ -623,36 +511,66 @@ class Jellyfin(private val suffix: String) : ConfigurableAnimeSource, AnimeHttpS
|
|||||||
val entry = entryValues[index] as String
|
val entry = entryValues[index] as String
|
||||||
preferences.edit().putString(key, entry).commit()
|
preferences.edit().putString(key, entry).commit()
|
||||||
}
|
}
|
||||||
}
|
}.also(screen::addPreference)
|
||||||
screen.addPreference(audioLangPref)
|
|
||||||
|
|
||||||
val metaTypePref = SwitchPreferenceCompat(screen.context).apply {
|
SwitchPreferenceCompat(screen.context).apply {
|
||||||
key = "preferred_meta_type"
|
key = PREF_INFO_TYPE
|
||||||
title = "Retrieve metadata from series"
|
title = "Retrieve metadata from series"
|
||||||
summary = """Enable this to retrieve metadata from series instead of season when applicable.""".trimMargin()
|
summary = """Enable this to retrieve metadata from series instead of season when applicable.""".trimMargin()
|
||||||
setDefaultValue(false)
|
setDefaultValue(PREF_INFO_DEFAULT)
|
||||||
|
|
||||||
setOnPreferenceChangeListener { _, newValue ->
|
setOnPreferenceChangeListener { _, newValue ->
|
||||||
val new = newValue as Boolean
|
val new = newValue as Boolean
|
||||||
preferences.edit().putBoolean(key, new).commit()
|
preferences.edit().putBoolean(key, new).commit()
|
||||||
}
|
}
|
||||||
}
|
}.also(screen::addPreference)
|
||||||
screen.addPreference(metaTypePref)
|
|
||||||
|
|
||||||
val trustCertificatePref = SwitchPreferenceCompat(screen.context).apply {
|
SwitchPreferenceCompat(screen.context).apply {
|
||||||
key = "preferred_trust_all_certs"
|
key = PREF_TRUST_CERT_KEY
|
||||||
title = "Trust all certificates"
|
title = "Trust all certificates"
|
||||||
summary = "Requires app restart to take effect."
|
summary = "Requires app restart to take effect."
|
||||||
setDefaultValue(false)
|
setDefaultValue(PREF_TRUST_CERT_DEFAULT)
|
||||||
|
|
||||||
setOnPreferenceChangeListener { _, newValue ->
|
setOnPreferenceChangeListener { _, newValue ->
|
||||||
val new = newValue as Boolean
|
val new = newValue as Boolean
|
||||||
preferences.edit().putBoolean(key, new).commit()
|
preferences.edit().putBoolean(key, new).commit()
|
||||||
}
|
}
|
||||||
}
|
}.also(screen::addPreference)
|
||||||
screen.addPreference(trustCertificatePref)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val SharedPreferences.getApiKey
|
||||||
|
get() = getString(APIKEY_KEY, null)
|
||||||
|
|
||||||
|
private val SharedPreferences.getUserId
|
||||||
|
get() = getString(USERID_KEY, null)
|
||||||
|
|
||||||
|
private val SharedPreferences.getHostUrl
|
||||||
|
get() = getString(HOSTURL_KEY, HOSTURL_DEFAULT)!!
|
||||||
|
|
||||||
|
private val SharedPreferences.getUserName
|
||||||
|
get() = getString(USERNAME_KEY, USERNAME_DEFAULT)!!
|
||||||
|
|
||||||
|
private val SharedPreferences.getPassword
|
||||||
|
get() = getString(PASSWORD_KEY, PASSWORD_DEFAULT)!!
|
||||||
|
|
||||||
|
private val SharedPreferences.getMediaLibId
|
||||||
|
get() = getString(MEDIALIB_KEY, MEDIALIB_DEFAULT)!!
|
||||||
|
|
||||||
|
private val SharedPreferences.getEpDetails
|
||||||
|
get() = getStringSet(PREF_EP_DETAILS_KEY, PREF_EP_DETAILS_DEFAULT)!!
|
||||||
|
|
||||||
|
private val SharedPreferences.getSubPref
|
||||||
|
get() = getString(PREF_SUB_KEY, PREF_SUB_DEFAULT)!!
|
||||||
|
|
||||||
|
private val SharedPreferences.getAudioPref
|
||||||
|
get() = getString(PREF_AUDIO_KEY, PREF_AUDIO_DEFAULT)!!
|
||||||
|
|
||||||
|
private val SharedPreferences.useSeriesData
|
||||||
|
get() = getBoolean(PREF_INFO_TYPE, PREF_INFO_DEFAULT)
|
||||||
|
|
||||||
|
private val SharedPreferences.getTrustCert
|
||||||
|
get() = getBoolean(PREF_TRUST_CERT_KEY, PREF_TRUST_CERT_DEFAULT)
|
||||||
|
|
||||||
private abstract class MediaLibPreference(context: Context) : ListPreference(context) {
|
private abstract class MediaLibPreference(context: Context) : ListPreference(context) {
|
||||||
abstract fun reload()
|
abstract fun reload()
|
||||||
}
|
}
|
||||||
@ -661,8 +579,8 @@ class Jellyfin(private val suffix: String) : ConfigurableAnimeSource, AnimeHttpS
|
|||||||
object : MediaLibPreference(screen.context) {
|
object : MediaLibPreference(screen.context) {
|
||||||
override fun reload() {
|
override fun reload() {
|
||||||
this.apply {
|
this.apply {
|
||||||
key = JFConstants.MEDIALIB_KEY
|
key = MEDIALIB_KEY
|
||||||
title = JFConstants.MEDIALIB_TITLE
|
title = "Select Media Library"
|
||||||
summary = "%s"
|
summary = "%s"
|
||||||
|
|
||||||
Thread {
|
Thread {
|
||||||
@ -670,17 +588,10 @@ class Jellyfin(private val suffix: String) : ConfigurableAnimeSource, AnimeHttpS
|
|||||||
val mediaLibsResponse = client.newCall(
|
val mediaLibsResponse = client.newCall(
|
||||||
GET("$baseUrl/Users/$userId/Items?api_key=$apiKey"),
|
GET("$baseUrl/Users/$userId/Items?api_key=$apiKey"),
|
||||||
).execute()
|
).execute()
|
||||||
val mediaJson = mediaLibsResponse.body.let { json.decodeFromString<ItemsResponse>(it.string()) }?.Items
|
val mediaJson = mediaLibsResponse.parseAs<ItemsDto>().items
|
||||||
|
|
||||||
val entriesArray = mutableListOf<String>()
|
val entriesArray = mediaJson.map { it.name }
|
||||||
val entriesValueArray = mutableListOf<String>()
|
val entriesValueArray = mediaJson.map { it.id }
|
||||||
|
|
||||||
if (mediaJson != null) {
|
|
||||||
for (media in mediaJson) {
|
|
||||||
entriesArray.add(media.Name)
|
|
||||||
entriesValueArray.add(media.Id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
entries = entriesArray.toTypedArray()
|
entries = entriesArray.toTypedArray()
|
||||||
entryValues = entriesValueArray.toTypedArray()
|
entryValues = entriesValueArray.toTypedArray()
|
||||||
|
@ -4,6 +4,8 @@ import android.content.SharedPreferences
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import eu.kanade.tachiyomi.AppInfo
|
import eu.kanade.tachiyomi.AppInfo
|
||||||
|
import eu.kanade.tachiyomi.animeextension.all.jellyfin.Jellyfin.Companion.APIKEY_KEY
|
||||||
|
import eu.kanade.tachiyomi.animeextension.all.jellyfin.Jellyfin.Companion.USERID_KEY
|
||||||
import eu.kanade.tachiyomi.network.POST
|
import eu.kanade.tachiyomi.network.POST
|
||||||
import eu.kanade.tachiyomi.util.parseAs
|
import eu.kanade.tachiyomi.util.parseAs
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
@ -27,8 +29,8 @@ class JellyfinAuthenticator(
|
|||||||
fun login(username: String, password: String): Pair<String?, String?> {
|
fun login(username: String, password: String): Pair<String?, String?> {
|
||||||
return runCatching {
|
return runCatching {
|
||||||
val authResult = authenticateWithPassword(username, password)
|
val authResult = authenticateWithPassword(username, password)
|
||||||
val key = authResult.AccessToken
|
val key = authResult.accessToken
|
||||||
val userId = authResult.SessionInfo.UserId
|
val userId = authResult.sessionInfo.userId
|
||||||
saveLogin(key, userId)
|
saveLogin(key, userId)
|
||||||
Pair(key, userId)
|
Pair(key, userId)
|
||||||
}.getOrElse {
|
}.getOrElse {
|
||||||
@ -37,7 +39,7 @@ class JellyfinAuthenticator(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun authenticateWithPassword(username: String, password: String): LoginResponse {
|
private fun authenticateWithPassword(username: String, password: String): LoginDto {
|
||||||
var deviceId = getPrefDeviceId()
|
var deviceId = getPrefDeviceId()
|
||||||
if (deviceId.isNullOrEmpty()) {
|
if (deviceId.isNullOrEmpty()) {
|
||||||
deviceId = getRandomString()
|
deviceId = getRandomString()
|
||||||
@ -69,8 +71,8 @@ class JellyfinAuthenticator(
|
|||||||
|
|
||||||
private fun saveLogin(key: String, userId: String) {
|
private fun saveLogin(key: String, userId: String) {
|
||||||
preferences.edit()
|
preferences.edit()
|
||||||
.putString(JFConstants.APIKEY_KEY, key)
|
.putString(APIKEY_KEY, key)
|
||||||
.putString(JFConstants.USERID_KEY, userId)
|
.putString(USERID_KEY, userId)
|
||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,51 +1,6 @@
|
|||||||
package eu.kanade.tachiyomi.animeextension.all.jellyfin
|
package eu.kanade.tachiyomi.animeextension.all.jellyfin
|
||||||
|
|
||||||
import android.content.SharedPreferences
|
object JellyfinConstants {
|
||||||
|
|
||||||
object JFConstants {
|
|
||||||
const val APIKEY_KEY = "api_key"
|
|
||||||
const val USERID_KEY = "user_id"
|
|
||||||
const val USERNAME_TITLE = "Username"
|
|
||||||
const val USERNAME_KEY = "username"
|
|
||||||
const val PASSWORD_TITLE = "Password"
|
|
||||||
const val PASSWORD_KEY = "password"
|
|
||||||
const val HOSTURL_TITLE = "Host URL"
|
|
||||||
const val HOSTURL_KEY = "host_url"
|
|
||||||
const val MEDIALIB_KEY = "library_pref"
|
|
||||||
const val MEDIALIB_TITLE = "Select Media Library"
|
|
||||||
|
|
||||||
const val HOSTURL_DEFAULT = "http://127.0.0.1:8096"
|
|
||||||
|
|
||||||
fun getPrefApiKey(preferences: SharedPreferences): String? = preferences.getString(
|
|
||||||
APIKEY_KEY,
|
|
||||||
null,
|
|
||||||
)
|
|
||||||
fun getPrefUserId(preferences: SharedPreferences): String? = preferences.getString(
|
|
||||||
USERID_KEY,
|
|
||||||
null,
|
|
||||||
)
|
|
||||||
fun getPrefHostUrl(preferences: SharedPreferences): String = preferences.getString(
|
|
||||||
HOSTURL_KEY,
|
|
||||||
HOSTURL_DEFAULT,
|
|
||||||
)!!
|
|
||||||
fun getPrefUsername(preferences: SharedPreferences): String = preferences.getString(
|
|
||||||
USERNAME_KEY,
|
|
||||||
"",
|
|
||||||
)!!
|
|
||||||
fun getPrefPassword(preferences: SharedPreferences): String = preferences.getString(
|
|
||||||
PASSWORD_KEY,
|
|
||||||
"",
|
|
||||||
)!!
|
|
||||||
fun getPrefParentId(preferences: SharedPreferences): String = preferences.getString(
|
|
||||||
MEDIALIB_KEY,
|
|
||||||
"",
|
|
||||||
)!!
|
|
||||||
|
|
||||||
const val PREF_AUDIO_KEY = "preferred_audioLang"
|
|
||||||
const val PREF_AUDIO_TITLE = "Preferred audio language"
|
|
||||||
const val PREF_SUB_KEY = "preferred_subLang"
|
|
||||||
const val PREF_SUB_TITLE = "Preferred sub language"
|
|
||||||
|
|
||||||
val QUALITIES_LIST = arrayOf(
|
val QUALITIES_LIST = arrayOf(
|
||||||
Quality(480, 360, 292000, 128000, "360p - 420 kbps"),
|
Quality(480, 360, 292000, 128000, "360p - 420 kbps"),
|
||||||
Quality(854, 480, 528000, 192000, "480p - 720 kbps"),
|
Quality(854, 480, 528000, 192000, "480p - 720 kbps"),
|
@ -0,0 +1,219 @@
|
|||||||
|
package eu.kanade.tachiyomi.animeextension.all.jellyfin
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.animeextension.all.jellyfin.Jellyfin.EpisodeType
|
||||||
|
import eu.kanade.tachiyomi.animesource.model.SAnime
|
||||||
|
import eu.kanade.tachiyomi.animesource.model.SEpisode
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
|
import org.jsoup.Jsoup
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class LoginDto(
|
||||||
|
@SerialName("AccessToken") val accessToken: String,
|
||||||
|
@SerialName("SessionInfo") val sessionInfo: LoginSessionDto,
|
||||||
|
) {
|
||||||
|
@Serializable
|
||||||
|
data class LoginSessionDto(
|
||||||
|
@SerialName("UserId") val userId: String,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ItemsDto(
|
||||||
|
@SerialName("Items") val items: List<ItemDto>,
|
||||||
|
@SerialName("TotalRecordCount") val itemCount: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ItemDto(
|
||||||
|
@SerialName("Name") val name: String,
|
||||||
|
@SerialName("Type") val type: String,
|
||||||
|
@SerialName("Id") val id: String,
|
||||||
|
@SerialName("LocationType") val locationType: String,
|
||||||
|
@SerialName("ImageTags") val imageTags: ImageDto,
|
||||||
|
@SerialName("SeriesId") val seriesId: String? = null,
|
||||||
|
@SerialName("SeriesName") val seriesName: String? = null,
|
||||||
|
|
||||||
|
// Details
|
||||||
|
@SerialName("Overview") val overview: String? = null,
|
||||||
|
@SerialName("Genres") val genres: List<String>? = null,
|
||||||
|
@SerialName("Studios") val studios: List<StudioDto>? = null,
|
||||||
|
|
||||||
|
// Only for series, not season
|
||||||
|
@SerialName("Status") val seriesStatus: String? = null,
|
||||||
|
|
||||||
|
// Episode
|
||||||
|
@SerialName("PremiereDate") val premiereData: String? = null,
|
||||||
|
@SerialName("RunTimeTicks") val runTime: Long? = null,
|
||||||
|
@SerialName("MediaSources") val mediaSources: List<MediaDto>? = null,
|
||||||
|
@SerialName("IndexNumber") val indexNumber: Int? = null,
|
||||||
|
) {
|
||||||
|
@Serializable
|
||||||
|
data class ImageDto(
|
||||||
|
@SerialName("Primary") val primary: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class StudioDto(
|
||||||
|
@SerialName("Name") val name: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun toSAnime(baseUrl: String, userId: String, apiKey: String): SAnime = SAnime.create().apply {
|
||||||
|
val httpUrl = baseUrl.toHttpUrl().newBuilder().apply {
|
||||||
|
addPathSegment("Users")
|
||||||
|
addPathSegment(userId)
|
||||||
|
addPathSegment("Items")
|
||||||
|
addPathSegment(id)
|
||||||
|
addQueryParameter("api_key", apiKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
thumbnail_url = "$baseUrl/Items/$id/Images/Primary?api_key=$apiKey"
|
||||||
|
|
||||||
|
when (type) {
|
||||||
|
"Season" -> {
|
||||||
|
// To prevent one extra GET request when fetching episodes
|
||||||
|
httpUrl.fragment("seriesId,${seriesId!!}")
|
||||||
|
|
||||||
|
if (locationType == "Virtual") {
|
||||||
|
title = seriesName!!
|
||||||
|
thumbnail_url = "$baseUrl/Items/$seriesId/Images/Primary?api_key=$apiKey"
|
||||||
|
} else {
|
||||||
|
title = "$seriesName $name"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use series as fallback
|
||||||
|
if (imageTags.primary == null) {
|
||||||
|
thumbnail_url = "$baseUrl/Items/$seriesId/Images/Primary?api_key=$apiKey"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"Movie" -> {
|
||||||
|
httpUrl.fragment("movie")
|
||||||
|
title = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
url = httpUrl.build().toString()
|
||||||
|
|
||||||
|
// Details
|
||||||
|
description = overview?.let {
|
||||||
|
Jsoup.parseBodyFragment(
|
||||||
|
it.replace("<br>", "br2n"),
|
||||||
|
).text().replace("br2n", "\n")
|
||||||
|
}
|
||||||
|
genre = genres?.joinToString(", ")
|
||||||
|
author = studios?.joinToString(", ") { it.name }
|
||||||
|
status = seriesStatus.parseStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String?.parseStatus(): Int = when (this) {
|
||||||
|
"Ended" -> SAnime.COMPLETED
|
||||||
|
"Continuing" -> SAnime.ONGOING
|
||||||
|
else -> SAnime.UNKNOWN
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================== Episodes ==============================
|
||||||
|
|
||||||
|
fun toSEpisode(
|
||||||
|
baseUrl: String,
|
||||||
|
userId: String,
|
||||||
|
apiKey: String,
|
||||||
|
epDetails: Set<String>,
|
||||||
|
epType: EpisodeType,
|
||||||
|
): SEpisode = SEpisode.create().apply {
|
||||||
|
when (epType) {
|
||||||
|
EpisodeType.MOVIE -> {
|
||||||
|
episode_number = 1F
|
||||||
|
name = "Movie"
|
||||||
|
}
|
||||||
|
EpisodeType.EPISODE -> {
|
||||||
|
episode_number = indexNumber?.toFloat() ?: 1F
|
||||||
|
name = "Ep. $indexNumber - ${this@ItemDto.name}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val extraInfo = buildList {
|
||||||
|
if (epDetails.contains("Overview") && overview != null && epType == EpisodeType.EPISODE) {
|
||||||
|
add(overview)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (epDetails.contains("Size") && mediaSources != null) {
|
||||||
|
mediaSources.first().size?.also {
|
||||||
|
add(it.formatBytes())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (epDetails.contains("Runtime") && runTime != null) {
|
||||||
|
add(runTime.formatTicks())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scanlator = extraInfo.joinToString(" • ")
|
||||||
|
premiereData?.also {
|
||||||
|
date_upload = parseDate(it.removeSuffix("Z"))
|
||||||
|
}
|
||||||
|
url = "$baseUrl/Users/$userId/Items/$id?api_key=$apiKey"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Long.formatBytes(): String = when {
|
||||||
|
this >= 1_000_000_000 -> "%.2f GB".format(this / 1_000_000_000.0)
|
||||||
|
this >= 1_000_000 -> "%.2f MB".format(this / 1_000_000.0)
|
||||||
|
this >= 1_000 -> "%.2f KB".format(this / 1_000.0)
|
||||||
|
this > 1 -> "$this bytes"
|
||||||
|
this == 1L -> "$this byte"
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Long.formatTicks(): String {
|
||||||
|
val seconds = this / 10_000_000
|
||||||
|
val minutes = seconds / 60
|
||||||
|
val hours = minutes / 60
|
||||||
|
|
||||||
|
val remainingSeconds = seconds % 60
|
||||||
|
val remainingMinutes = minutes % 60
|
||||||
|
|
||||||
|
val formattedHours = if (hours > 0) "${hours}h " else ""
|
||||||
|
val formattedMinutes = if (remainingMinutes > 0) "${remainingMinutes}m " else ""
|
||||||
|
val formattedSeconds = "${remainingSeconds}s"
|
||||||
|
|
||||||
|
return "$formattedHours$formattedMinutes$formattedSeconds".trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseDate(dateStr: String): Long {
|
||||||
|
return runCatching { DATE_FORMATTER.parse(dateStr)?.time }
|
||||||
|
.getOrNull() ?: 0L
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val DATE_FORMATTER by lazy {
|
||||||
|
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSSSSS", Locale.ENGLISH)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SessionDto(
|
||||||
|
@SerialName("MediaSources") val mediaSources: List<MediaDto>,
|
||||||
|
@SerialName("PlaySessionId") val playSessionId: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class MediaDto(
|
||||||
|
@SerialName("Size") val size: Long? = null,
|
||||||
|
@SerialName("MediaStreams") val mediaStreams: List<MediaStreamDto>,
|
||||||
|
) {
|
||||||
|
@Serializable
|
||||||
|
data class MediaStreamDto(
|
||||||
|
@SerialName("Codec") val codec: String,
|
||||||
|
@SerialName("Index") val index: Int,
|
||||||
|
@SerialName("Type") val type: String,
|
||||||
|
@SerialName("SupportsExternalStream") val supportsExternalStream: Boolean,
|
||||||
|
@SerialName("IsExternal") val isExternal: Boolean,
|
||||||
|
@SerialName("Language") val lang: String? = null,
|
||||||
|
@SerialName("DisplayTitle") val displayTitle: String? = null,
|
||||||
|
@SerialName("Height") val height: Int? = null,
|
||||||
|
@SerialName("Width") val width: Int? = null,
|
||||||
|
)
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user