refactor(all/jellyfin): Refactor stuff (#2804)

This commit is contained in:
Secozzi 2024-01-21 18:00:57 +00:00 committed by GitHub
parent ed55a9a3b0
commit c976b048e5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 494 additions and 485 deletions

View File

@ -1,7 +1,7 @@
ext {
extName = 'Jellyfin'
extClass = '.JellyfinFactory'
extVersionCode = 11
extVersionCode = 12
}
apply from: "$rootDir/common.gradle"

View File

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

View File

@ -8,6 +8,7 @@ import android.text.InputType
import android.widget.Toast
import androidx.preference.EditTextPreference
import androidx.preference.ListPreference
import androidx.preference.MultiSelectListPreference
import androidx.preference.PreferenceScreen
import androidx.preference.SwitchPreferenceCompat
import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource
@ -19,28 +20,21 @@ import eu.kanade.tachiyomi.animesource.model.Track
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.animesource.online.AnimeHttpSource
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.awaitSuccess
import eu.kanade.tachiyomi.util.parseAs
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import okhttp3.Dns
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import org.jsoup.Jsoup
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
import java.security.cert.X509Certificate
import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManager
import javax.net.ssl.X509TrustManager
import kotlin.math.ceil
import kotlin.math.floor
class Jellyfin(private val suffix: String) : ConfigurableAnimeSource, AnimeHttpSource() {
@ -50,8 +44,6 @@ class Jellyfin(private val suffix: String) : ConfigurableAnimeSource, AnimeHttpS
override val supportsLatest = true
private val json: Json by injectLazy()
private fun getUnsafeOkHttpClient(): OkHttpClient {
// Create a trust manager that does not validate certificate chains
val trustAllCerts = arrayOf<TrustManager>(
@ -81,7 +73,7 @@ class Jellyfin(private val suffix: String) : ConfigurableAnimeSource, AnimeHttpS
}
override val client by lazy {
if (preferences.getBoolean("preferred_trust_all_certs", false)) {
if (preferences.getTrustCert) {
getUnsafeOkHttpClient()
} else {
network.client
@ -94,13 +86,13 @@ class Jellyfin(private val suffix: String) : ConfigurableAnimeSource, AnimeHttpS
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
}
override var baseUrl = JFConstants.getPrefHostUrl(preferences)
override var baseUrl = preferences.getHostUrl
private var username = JFConstants.getPrefUsername(preferences)
private var password = JFConstants.getPrefPassword(preferences)
private var parentId = JFConstants.getPrefParentId(preferences)
private var apiKey = JFConstants.getPrefApiKey(preferences)
private var userId = JFConstants.getPrefUserId(preferences)
private var username = preferences.getUserName
private var password = preferences.getPassword
private var parentId = preferences.getMediaLibId
private var apiKey = preferences.getApiKey
private var userId = preferences.getUserId
init {
login(false)
@ -108,9 +100,9 @@ class Jellyfin(private val suffix: String) : ConfigurableAnimeSource, AnimeHttpS
private fun login(new: Boolean, context: Context? = null): Boolean? {
if (apiKey == null || userId == null || new) {
baseUrl = JFConstants.getPrefHostUrl(preferences)
username = JFConstants.getPrefUsername(preferences)
password = JFConstants.getPrefPassword(preferences)
baseUrl = preferences.getHostUrl
username = preferences.getUserName
password = preferences.getPassword
if (username.isEmpty() || password.isEmpty()) {
if (username != "demo") return null
}
@ -133,314 +125,230 @@ class Jellyfin(private val suffix: String) : ConfigurableAnimeSource, AnimeHttpS
// ============================== Popular ===============================
override fun popularAnimeParse(response: Response): AnimesPage = throw UnsupportedOperationException()
override suspend fun getPopularAnime(page: Int): AnimesPage {
return client.newCall(popularAnimeRequest(page))
.awaitSuccess()
.use { response ->
popularAnimeParsePage(response, page)
}
}
override fun popularAnimeRequest(page: Int): Request {
require(parentId.isNotEmpty()) { "Select library in the extension settings." }
val startIndex = (page - 1) * 20
val startIndex = (page - 1) * SEASONS_LIMIT
val url = "$baseUrl/Users/$userId/Items".toHttpUrl().newBuilder().apply {
addQueryParameter("api_key", apiKey)
addQueryParameter("StartIndex", startIndex.toString())
addQueryParameter("Limit", "20")
addQueryParameter("Limit", SEASONS_LIMIT.toString())
addQueryParameter("Recursive", "true")
addQueryParameter("SortBy", "SortName")
addQueryParameter("SortOrder", "Ascending")
addQueryParameter("includeItemTypes", "Movie,Season,BoxSet")
addQueryParameter("IncludeItemTypes", "Movie,Season")
addQueryParameter("ImageTypeLimit", "1")
addQueryParameter("ParentId", parentId)
addQueryParameter("EnableImageTypes", "Primary")
}
}.build()
return GET(url.toString())
return GET(url)
}
private fun popularAnimeParsePage(response: Response, page: Int): AnimesPage {
val (list, hasNext) = animeParse(response, page)
return AnimesPage(
list.sortedBy { it.title },
hasNext,
)
override fun popularAnimeParse(response: Response): AnimesPage {
val page = response.request.url.queryParameter("StartIndex")!!.toInt() / SEASONS_LIMIT + 1
val data = response.parseAs<ItemsDto>()
val animeList = data.items.map { it.toSAnime(baseUrl, userId!!, apiKey!!) }
return AnimesPage(animeList, SEASONS_LIMIT * page < data.itemCount)
}
// =============================== Latest ===============================
override fun latestUpdatesParse(response: Response) = throw UnsupportedOperationException()
override suspend fun getLatestUpdates(page: Int): AnimesPage {
return client.newCall(latestUpdatesRequest(page))
.awaitSuccess()
.use { response ->
latestUpdatesParsePage(response, page)
}
}
override fun latestUpdatesRequest(page: Int): Request {
require(parentId.isNotEmpty()) { "Select library in the extension settings." }
val startIndex = (page - 1) * 20
val url = popularAnimeRequest(page).url.newBuilder().apply {
setQueryParameter("SortBy", "DateCreated,SortName")
setQueryParameter("SortOrder", "Descending")
}.build()
val url = "$baseUrl/Users/$userId/Items".toHttpUrl().newBuilder().apply {
addQueryParameter("api_key", apiKey)
addQueryParameter("StartIndex", startIndex.toString())
addQueryParameter("Limit", "20")
addQueryParameter("Recursive", "true")
addQueryParameter("SortBy", "DateCreated,SortName")
addQueryParameter("SortOrder", "Descending")
addQueryParameter("includeItemTypes", "Movie,Season,BoxSet")
addQueryParameter("ImageTypeLimit", "1")
addQueryParameter("ParentId", parentId)
addQueryParameter("EnableImageTypes", "Primary")
}
return GET(url.toString())
return GET(url)
}
private fun latestUpdatesParsePage(response: Response, page: Int) = animeParse(response, page)
override fun latestUpdatesParse(response: Response): AnimesPage =
popularAnimeParse(response)
// =============================== Search ===============================
override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request = throw UnsupportedOperationException()
override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request {
val url = popularAnimeRequest(page).url.newBuilder().apply {
// Search for series, rather than seasons, since season names can just be "Season 1"
setQueryParameter("IncludeItemTypes", "Movie,Series")
setQueryParameter("Limit", SERIES_LIMIT.toString())
setQueryParameter("SearchTerm", query)
}.build()
override fun searchAnimeParse(response: Response) = throw UnsupportedOperationException()
override suspend fun getSearchAnime(page: Int, query: String, filters: AnimeFilterList): AnimesPage {
require(parentId.isNotEmpty()) { "Select library in the extension settings." }
val startIndex = (page - 1) * 5
val url = "$baseUrl/Users/$userId/Items".toHttpUrl().newBuilder().apply {
addQueryParameter("api_key", apiKey)
addQueryParameter("StartIndex", startIndex.toString())
addQueryParameter("Limit", "5")
addQueryParameter("Recursive", "true")
addQueryParameter("SortBy", "SortName")
addQueryParameter("SortOrder", "Ascending")
addQueryParameter("IncludeItemTypes", "Series,Movie,BoxSet")
addQueryParameter("ImageTypeLimit", "1")
addQueryParameter("EnableImageTypes", "Primary")
addQueryParameter("ParentId", parentId)
addQueryParameter("SearchTerm", query)
}
val items = client.newCall(
GET(url.build().toString(), headers = headers),
).execute().parseAs<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)
return GET(url)
}
private fun getAnimeFromMovie(movieList: List<ItemsResponse.Item>): List<SAnime> {
return movieList.map {
SAnime.create().apply {
title = it.Name
thumbnail_url = "$baseUrl/Items/${it.Id}/Images/Primary?api_key=$apiKey"
url = LinkData(
"/Users/$userId/Items/${it.Id}?api_key=$apiKey",
it.Id,
it.Id,
).toJsonString()
}
}
}
override fun searchAnimeParse(response: Response): AnimesPage {
val page = response.request.url.queryParameter("StartIndex")!!.toInt() / SERIES_LIMIT + 1
val data = response.parseAs<ItemsDto>()
private fun getAnimeFromId(id: String): List<SAnime> {
val url = "$baseUrl/Users/$userId/Items".toHttpUrl().newBuilder().apply {
addQueryParameter("api_key", apiKey)
addQueryParameter("Recursive", "true")
addQueryParameter("SortBy", "SortName")
addQueryParameter("SortOrder", "Ascending")
addQueryParameter("includeItemTypes", "Movie,Series,Season")
addQueryParameter("ImageTypeLimit", "1")
addQueryParameter("EnableImageTypes", "Primary")
addQueryParameter("ParentId", id)
// Get all seasons from series
val animeList = data.items.flatMap { series ->
val seasonsUrl = popularAnimeRequest(1).url.newBuilder().apply {
setQueryParameter("ParentId", series.id)
removeAllQueryParameters("StartIndex")
removeAllQueryParameters("Limit")
}.build()
val seasonsData = client.newCall(
GET(seasonsUrl),
).execute().parseAs<ItemsDto>()
seasonsData.items.map { it.toSAnime(baseUrl, userId!!, apiKey!!) }
}
val response = client.newCall(
GET(url.build().toString()),
).execute()
return animeParse(response, 0).animes
return AnimesPage(animeList, SERIES_LIMIT * page < data.itemCount)
}
// =========================== Anime Details ============================
override fun animeDetailsRequest(anime: SAnime): Request {
val mediaId = json.decodeFromString<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().apply {
addQueryParameter("api_key", apiKey)
addQueryParameter("fields", "Studios")
}
return GET(url.toString())
if (!anime.url.startsWith("http")) throw Exception("Migrate from jellyfin to jellyfin")
return GET(anime.url)
}
override fun animeDetailsParse(response: Response): SAnime {
val info = response.parseAs<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()
if (info.Genres != null) anime.genre = info.Genres.joinToString(", ")
if (!info.Studios.isNullOrEmpty()) {
anime.author = info.Studios.mapNotNull { it.Name }.joinToString(", ")
} else if (info.SeriesStudio != null) anime.author = info.SeriesStudio
anime.description = if (info.Overview != null) {
Jsoup.parse(
info.Overview
.replace("<br>\n", "br2n")
.replace("<br>", "br2n")
.replace("\n", "br2n"),
).text().replace("br2n", "\n")
client.newCall(
GET(url),
).execute().parseAs<ItemDto>()
} else {
""
data
}
if (info.Type == "Movie") {
anime.status = SAnime.COMPLETED
}
anime.title = if (info.SeriesName == null) {
info.Name
} else {
"${info.SeriesName} ${info.Name}"
}
return anime
return infoData.toSAnime(baseUrl, userId!!, apiKey!!)
}
// ============================== Episodes ==============================
override fun episodeListRequest(anime: SAnime): Request {
val mediaId = json.decodeFromString<LinkData>(anime.url)
return GET(baseUrl + mediaId.path, headers = headers)
if (!anime.url.startsWith("http")) throw Exception("Migrate from jellyfin to jellyfin")
val httpUrl = anime.url.toHttpUrl()
val fragment = httpUrl.fragment!!
// Get episodes of season
val url = if (fragment.startsWith("seriesId")) {
httpUrl.newBuilder().apply {
encodedPath("/")
encodedQuery(null)
fragment(null)
addPathSegment("Shows")
addPathSegment(fragment.split(",").last())
addPathSegment("Episodes")
addQueryParameter("api_key", apiKey)
addQueryParameter("seasonId", httpUrl.pathSegments.last())
addQueryParameter("userId", userId)
addQueryParameter("Fields", "Overview,MediaSources")
}.build()
} else if (fragment.startsWith("movie")) {
httpUrl.newBuilder().fragment(null).build()
} else {
httpUrl
}
return GET(url)
}
override fun episodeListParse(response: Response): List<SEpisode> {
val epDetails = preferences.getEpDetails
val episodeList = if (response.request.url.toString().startsWith("$baseUrl/Users/")) {
val parsed = json.decodeFromString<ItemsResponse.Item>(response.body.string())
listOf(
SEpisode.create().apply {
setUrlWithoutDomain(response.request.url.toString())
name = "Movie ${parsed.Name}"
episode_number = 1.0F
},
)
val data = response.parseAs<ItemDto>()
listOf(data.toSEpisode(baseUrl, userId!!, apiKey!!, epDetails, EpisodeType.MOVIE))
} else {
val parsed = response.parseAs<ItemsResponse>()
parsed.Items.map { ep ->
val namePrefix = if (ep.IndexNumber == null) {
""
} else {
val formattedEpNum = if (floor(ep.IndexNumber) == ceil(ep.IndexNumber)) {
ep.IndexNumber.toInt()
} else {
ep.IndexNumber.toFloat()
}
"Episode $formattedEpNum "
}
SEpisode.create().apply {
name = "$namePrefix${ep.Name}"
episode_number = ep.IndexNumber ?: 0F
url = "/Users/$userId/Items/${ep.Id}?api_key=$apiKey"
}
val data = response.parseAs<ItemsDto>()
data.items.map {
it.toSEpisode(baseUrl, userId!!, apiKey!!, epDetails, EpisodeType.EPISODE)
}
}
return episodeList.reversed()
}
enum class EpisodeType {
EPISODE,
MOVIE,
}
// ============================ Video Links =============================
override fun videoListRequest(episode: SEpisode): Request {
if (!episode.url.startsWith("http")) throw Exception("Migrate from jellyfin to jellyfin")
return GET(episode.url)
}
override fun videoListParse(response: Response): List<Video> {
val videoList = mutableListOf<Video>()
val id = response.parseAs<ItemsResponse.Item>().Id
val id = response.parseAs<ItemDto>().id
val parsed = client.newCall(
val sessionData = client.newCall(
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 externalSubtitleList = mutableListOf<Track>()
val prefSub = preferences.getString(JFConstants.PREF_SUB_KEY, "eng")!!
val prefAudio = preferences.getString(JFConstants.PREF_AUDIO_KEY, "jpn")!!
val prefSub = preferences.getSubPref
val prefAudio = preferences.getAudioPref
var audioIndex = 1
var subIndex: Int? = null
var width = 1920
var height = 1080
parsed.MediaSources.first().MediaStreams.forEach { media ->
when (media.Type) {
"Subtitle" -> {
if (media.SupportsExternalStream) {
val subUrl = "$baseUrl/Videos/$id/$id/Subtitles/${media.Index}/0/Stream.${media.Codec}?api_key=$apiKey"
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
}
}
sessionData.mediaSources.first().mediaStreams.forEach { media ->
when (media.type) {
"Video" -> {
width = media.width!!
height = media.height!!
}
"Audio" -> {
if (media.Language != null && media.Language == prefAudio) {
audioIndex = media.Index
if (media.lang != null && media.lang == prefAudio) {
audioIndex = media.index
}
}
"Video" -> {
width = media.Width!!
height = media.Height!!
"Subtitle" -> {
if (media.supportsExternalStream) {
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
JFConstants.QUALITIES_LIST.forEach { quality ->
JellyfinConstants.QUALITIES_LIST.forEach { quality ->
if (width < quality.width && height < quality.height) {
val url = "$baseUrl/Videos/$id/stream?static=True&api_key=$apiKey"
videoList.add(Video(url, "Source", url, subtitleTracks = externalSubtitleList))
@ -463,7 +371,7 @@ class Jellyfin(private val suffix: String) : ConfigurableAnimeSource, AnimeHttpS
"AudioBitrate",
quality.audioBitrate.toString(),
)
addQueryParameter("PlaySessionId", parsed.PlaySessionId)
addQueryParameter("PlaySessionId", sessionData.playSessionId)
addQueryParameter("TranscodingMaxAudioChannels", "6")
addQueryParameter("RequireAvc", "false")
addQueryParameter("SegmentContainer", "ts")
@ -474,7 +382,6 @@ class Jellyfin(private val suffix: String) : ConfigurableAnimeSource, AnimeHttpS
addQueryParameter("h264-deinterlace", "true")
addQueryParameter("TranscodeReasons", "VideoCodecNotSupported,AudioCodecNotSupported,ContainerBitrateExceedsLimit")
}
videoList.add(Video(url.toString(), quality.description, url.toString(), subtitleTracks = subtitleList))
}
}
@ -487,83 +394,49 @@ class Jellyfin(private val suffix: String) : ConfigurableAnimeSource, AnimeHttpS
// ============================= Utilities ==============================
private fun animeParse(response: Response, page: Int): AnimesPage {
val items = response.parseAs<ItemsResponse>()
companion object {
const val APIKEY_KEY = "api_key"
const val USERID_KEY = "user_id"
val animeList = items.Items.flatMap { item ->
val anime = SAnime.create()
private const val HOSTURL_KEY = "host_url"
private const val HOSTURL_DEFAULT = "http://127.0.0.1:8096"
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"
}
private const val USERNAME_KEY = "username"
private const val USERNAME_DEFAULT = ""
// If season doesn't have image, fallback to series image
if (item.ImageTags.Primary == null) {
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")
}
private const val PASSWORD_KEY = "password"
private const val PASSWORD_DEFAULT = ""
val response = client.newCall(
GET(url.build().toString(), headers = headers),
).execute()
animeParse(response, page).animes
}
else -> emptyList()
}
}
private const val MEDIALIB_KEY = "library_pref"
private const val MEDIALIB_DEFAULT = ""
return AnimesPage(animeList, 20 * page < items.TotalRecordCount)
}
private const val SEASONS_LIMIT = 20
private const val SERIES_LIMIT = 5
private fun LinkData.toJsonString(): String {
return json.encodeToString(this)
private const val PREF_EP_DETAILS_KEY = "pref_episode_details_key"
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) {
val mediaLibPref = medialibPreference(screen)
screen.addPreference(
screen.editTextPreference(
JFConstants.HOSTURL_KEY,
JFConstants.HOSTURL_TITLE,
JFConstants.HOSTURL_DEFAULT,
HOSTURL_KEY,
"Host URL",
HOSTURL_DEFAULT,
baseUrl,
false,
"",
@ -572,20 +445,20 @@ class Jellyfin(private val suffix: String) : ConfigurableAnimeSource, AnimeHttpS
)
screen.addPreference(
screen.editTextPreference(
JFConstants.USERNAME_KEY,
JFConstants.USERNAME_TITLE,
"",
USERNAME_KEY,
"Username",
USERNAME_DEFAULT,
username,
false,
"",
"The account username",
mediaLibPref,
),
)
screen.addPreference(
screen.editTextPreference(
JFConstants.PASSWORD_KEY,
JFConstants.PASSWORD_TITLE,
"",
PASSWORD_KEY,
"Password",
PASSWORD_DEFAULT,
password,
true,
"••••••••",
@ -593,12 +466,27 @@ class Jellyfin(private val suffix: String) : ConfigurableAnimeSource, AnimeHttpS
),
)
screen.addPreference(mediaLibPref)
val subLangPref = ListPreference(screen.context).apply {
key = JFConstants.PREF_SUB_KEY
title = JFConstants.PREF_SUB_TITLE
entries = JFConstants.PREF_ENTRIES
entryValues = JFConstants.PREF_VALUES
setDefaultValue("eng")
MultiSelectListPreference(screen.context).apply {
key = PREF_EP_DETAILS_KEY
title = "Additional details for episodes"
summary = "Show additional details about an episode in the scanlator field"
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"
setOnPreferenceChangeListener { _, newValue ->
@ -607,14 +495,14 @@ class Jellyfin(private val suffix: String) : ConfigurableAnimeSource, AnimeHttpS
val entry = entryValues[index] as String
preferences.edit().putString(key, entry).commit()
}
}
screen.addPreference(subLangPref)
val audioLangPref = ListPreference(screen.context).apply {
key = JFConstants.PREF_AUDIO_KEY
title = JFConstants.PREF_AUDIO_TITLE
entries = JFConstants.PREF_ENTRIES
entryValues = JFConstants.PREF_VALUES
setDefaultValue("jpn")
}.also(screen::addPreference)
ListPreference(screen.context).apply {
key = PREF_AUDIO_KEY
title = "Preferred audio language"
entries = JellyfinConstants.PREF_ENTRIES
entryValues = JellyfinConstants.PREF_VALUES
setDefaultValue(PREF_AUDIO_DEFAULT)
summary = "%s"
setOnPreferenceChangeListener { _, newValue ->
@ -623,36 +511,66 @@ class Jellyfin(private val suffix: String) : ConfigurableAnimeSource, AnimeHttpS
val entry = entryValues[index] as String
preferences.edit().putString(key, entry).commit()
}
}
screen.addPreference(audioLangPref)
}.also(screen::addPreference)
val metaTypePref = SwitchPreferenceCompat(screen.context).apply {
key = "preferred_meta_type"
SwitchPreferenceCompat(screen.context).apply {
key = PREF_INFO_TYPE
title = "Retrieve metadata from series"
summary = """Enable this to retrieve metadata from series instead of season when applicable.""".trimMargin()
setDefaultValue(false)
setDefaultValue(PREF_INFO_DEFAULT)
setOnPreferenceChangeListener { _, newValue ->
val new = newValue as Boolean
preferences.edit().putBoolean(key, new).commit()
}
}
screen.addPreference(metaTypePref)
}.also(screen::addPreference)
val trustCertificatePref = SwitchPreferenceCompat(screen.context).apply {
key = "preferred_trust_all_certs"
SwitchPreferenceCompat(screen.context).apply {
key = PREF_TRUST_CERT_KEY
title = "Trust all certificates"
summary = "Requires app restart to take effect."
setDefaultValue(false)
setDefaultValue(PREF_TRUST_CERT_DEFAULT)
setOnPreferenceChangeListener { _, newValue ->
val new = newValue as Boolean
preferences.edit().putBoolean(key, new).commit()
}
}
screen.addPreference(trustCertificatePref)
}.also(screen::addPreference)
}
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) {
abstract fun reload()
}
@ -661,8 +579,8 @@ class Jellyfin(private val suffix: String) : ConfigurableAnimeSource, AnimeHttpS
object : MediaLibPreference(screen.context) {
override fun reload() {
this.apply {
key = JFConstants.MEDIALIB_KEY
title = JFConstants.MEDIALIB_TITLE
key = MEDIALIB_KEY
title = "Select Media Library"
summary = "%s"
Thread {
@ -670,17 +588,10 @@ class Jellyfin(private val suffix: String) : ConfigurableAnimeSource, AnimeHttpS
val mediaLibsResponse = client.newCall(
GET("$baseUrl/Users/$userId/Items?api_key=$apiKey"),
).execute()
val mediaJson = mediaLibsResponse.body.let { json.decodeFromString<ItemsResponse>(it.string()) }?.Items
val mediaJson = mediaLibsResponse.parseAs<ItemsDto>().items
val entriesArray = mutableListOf<String>()
val entriesValueArray = mutableListOf<String>()
if (mediaJson != null) {
for (media in mediaJson) {
entriesArray.add(media.Name)
entriesValueArray.add(media.Id)
}
}
val entriesArray = mediaJson.map { it.name }
val entriesValueArray = mediaJson.map { it.id }
entries = entriesArray.toTypedArray()
entryValues = entriesValueArray.toTypedArray()

View File

@ -4,6 +4,8 @@ import android.content.SharedPreferences
import android.os.Build
import android.util.Log
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.util.parseAs
import kotlinx.serialization.encodeToString
@ -27,8 +29,8 @@ class JellyfinAuthenticator(
fun login(username: String, password: String): Pair<String?, String?> {
return runCatching {
val authResult = authenticateWithPassword(username, password)
val key = authResult.AccessToken
val userId = authResult.SessionInfo.UserId
val key = authResult.accessToken
val userId = authResult.sessionInfo.userId
saveLogin(key, userId)
Pair(key, userId)
}.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()
if (deviceId.isNullOrEmpty()) {
deviceId = getRandomString()
@ -69,8 +71,8 @@ class JellyfinAuthenticator(
private fun saveLogin(key: String, userId: String) {
preferences.edit()
.putString(JFConstants.APIKEY_KEY, key)
.putString(JFConstants.USERID_KEY, userId)
.putString(APIKEY_KEY, key)
.putString(USERID_KEY, userId)
.apply()
}

View File

@ -1,51 +1,6 @@
package eu.kanade.tachiyomi.animeextension.all.jellyfin
import android.content.SharedPreferences
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"
object JellyfinConstants {
val QUALITIES_LIST = arrayOf(
Quality(480, 360, 292000, 128000, "360p - 420 kbps"),
Quality(854, 480, 528000, 192000, "480p - 720 kbps"),

View File

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