Initial commit

This commit is contained in:
almightyhak 2024-06-20 11:54:12 +07:00
commit 98ed7e8839
2263 changed files with 108711 additions and 0 deletions

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

View file

@ -0,0 +1,803 @@
package eu.kanade.tachiyomi.animeextension.all.jellyfin
import android.annotation.SuppressLint
import android.app.Application
import android.content.Context
import android.content.SharedPreferences
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
import eu.kanade.tachiyomi.animesource.UnmeteredSource
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
import eu.kanade.tachiyomi.animesource.model.AnimesPage
import eu.kanade.tachiyomi.animesource.model.SAnime
import eu.kanade.tachiyomi.animesource.model.SEpisode
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.util.parseAs
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import okhttp3.Dns
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.security.MessageDigest
import java.security.cert.X509Certificate
import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManager
import javax.net.ssl.X509TrustManager
class Jellyfin(private val suffix: String) : ConfigurableAnimeSource, AnimeHttpSource(), UnmeteredSource {
override val baseUrl by lazy { getPrefBaseUrl() }
override val lang = "all"
override val name by lazy { "Jellyfin (${getCustomLabel()})" }
override val supportsLatest = true
private fun getUnsafeOkHttpClient(): OkHttpClient {
// Create a trust manager that does not validate certificate chains
val trustAllCerts = arrayOf<TrustManager>(
@SuppressLint("CustomX509TrustManager")
object : X509TrustManager {
@SuppressLint("TrustAllX509TrustManager")
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) {
}
@SuppressLint("TrustAllX509TrustManager")
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) {
}
override fun getAcceptedIssuers() = arrayOf<X509Certificate>()
},
)
// Install the all-trusting trust manager
val sslContext = SSLContext.getInstance("SSL")
sslContext.init(null, trustAllCerts, java.security.SecureRandom())
// Create an ssl socket factory with our all-trusting manager
val sslSocketFactory = sslContext.socketFactory
return network.client.newBuilder()
.sslSocketFactory(sslSocketFactory, trustAllCerts[0] as X509TrustManager)
.hostnameVerifier { _, _ -> true }.build()
}
override val client by lazy {
if (preferences.getTrustCert) {
getUnsafeOkHttpClient()
} else {
network.client
}.newBuilder()
.dns(Dns.SYSTEM)
.build()
}
override val id by lazy {
val key = "jellyfin" + (if (suffix == "1") "" else " ($suffix)") + "/all/$versionId"
val bytes = MessageDigest.getInstance("MD5").digest(key.toByteArray())
(0..7).map { bytes[it].toLong() and 0xff shl 8 * (7 - it) }.reduce(Long::or) and Long.MAX_VALUE
}
internal val preferences: SharedPreferences by lazy {
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
}
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)
}
private fun login(new: Boolean, context: Context? = null): Boolean? {
if (apiKey == null || userId == null || new) {
username = preferences.getUserName
password = preferences.getPassword
if (username.isEmpty() || password.isEmpty()) {
if (username != "demo") return null
}
val (newKey, newUid) = runBlocking {
withContext(Dispatchers.IO) {
JellyfinAuthenticator(preferences, getPrefBaseUrl(), client)
.login(username, password)
}
}
if (newKey != null && newUid != null) {
apiKey = newKey
userId = newUid
} else {
context?.let { Toast.makeText(it, "Login failed.", Toast.LENGTH_LONG).show() }
return false
}
}
return true
}
// ============================== Popular ===============================
override fun popularAnimeRequest(page: Int): Request {
require(parentId.isNotEmpty()) { "Select library in the extension settings." }
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", SEASONS_LIMIT.toString())
addQueryParameter("Recursive", "true")
addQueryParameter("SortBy", "SortName")
addQueryParameter("SortOrder", "Ascending")
addQueryParameter("IncludeItemTypes", "Movie,Season,BoxSet")
addQueryParameter("ImageTypeLimit", "1")
addQueryParameter("ParentId", parentId)
addQueryParameter("EnableImageTypes", "Primary")
}.build()
return GET(url)
}
override fun popularAnimeParse(response: Response): AnimesPage {
val splitCollections = preferences.getSplitCol
val page = response.request.url.queryParameter("StartIndex")!!.toInt() / SEASONS_LIMIT + 1
val data = response.parseAs<ItemsDto>()
val animeList = data.items.flatMap {
if (it.type == "BoxSet" && splitCollections) {
val url = popularAnimeRequest(page).url.newBuilder().apply {
setQueryParameter("ParentId", it.id)
}.build()
popularAnimeParse(
client.newCall(GET(url)).execute(),
).animes
} else {
listOf(it.toSAnime(baseUrl, userId!!, apiKey!!))
}
}
return AnimesPage(animeList, SEASONS_LIMIT * page < data.itemCount)
}
// =============================== Latest ===============================
override fun latestUpdatesRequest(page: Int): Request {
val url = popularAnimeRequest(page).url.newBuilder().apply {
setQueryParameter("SortBy", "DateCreated,SortName")
setQueryParameter("SortOrder", "Descending")
}.build()
return GET(url)
}
override fun latestUpdatesParse(response: Response): AnimesPage =
popularAnimeParse(response)
// =============================== Search ===============================
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()
return GET(url)
}
override fun searchAnimeParse(response: Response): AnimesPage {
val page = response.request.url.queryParameter("StartIndex")!!.toInt() / SERIES_LIMIT + 1
val data = response.parseAs<ItemsDto>()
// 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!!) }
}
return AnimesPage(animeList, SERIES_LIMIT * page < data.itemCount)
}
// =========================== Anime Details ============================
override fun animeDetailsRequest(anime: SAnime): Request {
if (!anime.url.startsWith("http")) throw Exception("Migrate from jellyfin to jellyfin")
return GET(anime.url)
}
override fun animeDetailsParse(response: Response): SAnime {
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()
}
client.newCall(
GET(url),
).execute().parseAs<ItemDto>()
} else {
data
}
return infoData.toSAnime(baseUrl, userId!!, apiKey!!)
}
// ============================== Episodes ==============================
override fun episodeListRequest(anime: SAnime): Request {
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 if (fragment.startsWith("boxSet")) {
val itemId = httpUrl.pathSegments[3]
httpUrl.newBuilder().apply {
removePathSegment(3)
addQueryParameter("Recursive", "true")
addQueryParameter("SortBy", "SortName")
addQueryParameter("SortOrder", "Ascending")
addQueryParameter("IncludeItemTypes", "Movie,Season,BoxSet,Series")
addQueryParameter("ParentId", itemId)
}.build()
} else if (fragment.startsWith("series")) {
val itemId = httpUrl.pathSegments[3]
httpUrl.newBuilder().apply {
encodedPath("/")
encodedQuery(null)
addPathSegment("Shows")
addPathSegment(itemId)
addPathSegment("Episodes")
addQueryParameter("api_key", apiKey)
}.build()
} else {
httpUrl
}
return GET(url)
}
override fun episodeListParse(response: Response): List<SEpisode> {
val httpUrl = response.request.url
val episodeList = if (httpUrl.fragment == "boxSet") {
val data = response.parseAs<ItemsDto>()
val animeList = data.items.map {
it.toSAnime(baseUrl, userId!!, apiKey!!)
}.sortedByDescending { it.title }
animeList.flatMap {
client.newCall(episodeListRequest(it))
.execute()
.let { res ->
episodeListParse(res, "${it.title} - ")
}
}
} else {
episodeListParse(response, "")
}
return if (preferences.sortEp) {
episodeList.sortedByDescending { it.date_upload }
} else {
episodeList
}
}
private fun episodeListParse(response: Response, prefix: String): List<SEpisode> {
val httpUrl = response.request.url
val epDetails = preferences.getEpDetails
return if (response.request.url.toString().startsWith("$baseUrl/Users/")) {
val data = response.parseAs<ItemDto>()
listOf(data.toSEpisode(baseUrl, userId!!, apiKey!!, epDetails, EpisodeType.MOVIE, prefix))
} else if (httpUrl.fragment == "series") {
val data = response.parseAs<ItemsDto>()
data.items.map {
val name = prefix + (it.seasonName?.let { "$it - " } ?: "")
it.toSEpisode(baseUrl, userId!!, apiKey!!, epDetails, EpisodeType.EPISODE, name)
}
} else {
val data = response.parseAs<ItemsDto>()
data.items.map {
it.toSEpisode(baseUrl, userId!!, apiKey!!, epDetails, EpisodeType.EPISODE, prefix)
}
}.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 id = response.parseAs<ItemDto>().id
val sessionData = client.newCall(
GET("$baseUrl/Items/$id/PlaybackInfo?userId=$userId&api_key=$apiKey"),
).execute().parseAs<SessionDto>()
val videoList = mutableListOf<Video>()
val subtitleList = mutableListOf<Track>()
val externalSubtitleList = mutableListOf<Track>()
val prefSub = preferences.getSubPref
val prefAudio = preferences.getAudioPref
var audioIndex = 1
var subIndex: Int? = null
var width = 1920
var height = 1080
sessionData.mediaSources.first().mediaStreams.forEach { media ->
when (media.type) {
"Video" -> {
width = media.width!!
height = media.height!!
}
"Audio" -> {
if (media.lang != null && media.lang == prefAudio) {
audioIndex = media.index
}
}
"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
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))
return videoList.reversed()
} else {
val url = "$baseUrl/videos/$id/main.m3u8".toHttpUrl().newBuilder().apply {
addQueryParameter("api_key", apiKey)
addQueryParameter("VideoCodec", "h264")
addQueryParameter("AudioCodec", "aac,mp3")
addQueryParameter("AudioStreamIndex", audioIndex.toString())
subIndex?.let { addQueryParameter("SubtitleStreamIndex", it.toString()) }
addQueryParameter("VideoCodec", "h264")
addQueryParameter("VideoCodec", "h264")
addQueryParameter(
"VideoBitrate",
quality.videoBitrate.toString(),
)
addQueryParameter(
"AudioBitrate",
quality.audioBitrate.toString(),
)
addQueryParameter("PlaySessionId", sessionData.playSessionId)
addQueryParameter("TranscodingMaxAudioChannels", "6")
addQueryParameter("RequireAvc", "false")
addQueryParameter("SegmentContainer", "ts")
addQueryParameter("MinSegments", "1")
addQueryParameter("BreakOnNonKeyFrames", "true")
addQueryParameter("h264-profile", "high,main,baseline,constrainedbaseline")
addQueryParameter("h264-level", "51")
addQueryParameter("h264-deinterlace", "true")
addQueryParameter("TranscodeReasons", "VideoCodecNotSupported,AudioCodecNotSupported,ContainerBitrateExceedsLimit")
}
videoList.add(Video(url.toString(), quality.description, url.toString(), subtitleTracks = subtitleList))
}
}
val url = "$baseUrl/Videos/$id/stream?static=True&api_key=$apiKey"
videoList.add(Video(url, "Source", url, subtitleTracks = externalSubtitleList))
return videoList.reversed()
}
// ============================= Utilities ==============================
companion object {
const val APIKEY_KEY = "api_key"
const val USERID_KEY = "user_id"
internal const val EXTRA_SOURCES_COUNT_KEY = "extraSourcesCount"
internal const val EXTRA_SOURCES_COUNT_DEFAULT = "3"
private val EXTRA_SOURCES_ENTRIES = (1..10).map { it.toString() }.toTypedArray()
private const val PREF_CUSTOM_LABEL_KEY = "pref_label"
private const val PREF_CUSTOM_LABEL_DEFAULT = ""
private const val HOSTURL_KEY = "host_url"
private const val HOSTURL_DEFAULT = "http://127.0.0.1:8096"
private const val USERNAME_KEY = "username"
private const val USERNAME_DEFAULT = ""
private const val PASSWORD_KEY = "password"
private const val PASSWORD_DEFAULT = ""
private const val MEDIALIB_KEY = "library_pref"
private const val MEDIALIB_DEFAULT = ""
private const val SEASONS_LIMIT = 20
private const val SERIES_LIMIT = 5
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
private const val PREF_SPLIT_COLLECTIONS_KEY = "preferred_split_col"
private const val PREF_SPLIT_COLLECTIONS_DEFAULT = false
private const val PREF_SORT_EPISODES_KEY = "preferred_sort_ep"
private const val PREF_SORT_EPISODES_DEFAULT = false
}
private fun getCustomLabel(): String =
preferences.getString(PREF_CUSTOM_LABEL_KEY, suffix)!!.ifBlank { suffix }
private fun getPrefBaseUrl(): String =
preferences.getString(HOSTURL_KEY, HOSTURL_DEFAULT)!!
override fun setupPreferenceScreen(screen: PreferenceScreen) {
if (suffix == "1") {
ListPreference(screen.context).apply {
key = EXTRA_SOURCES_COUNT_KEY
title = "Number of sources"
summary = "Number of jellyfin sources to create. There will always be at least one Jellyfin source."
entries = EXTRA_SOURCES_ENTRIES
entryValues = EXTRA_SOURCES_ENTRIES
setDefaultValue(EXTRA_SOURCES_COUNT_DEFAULT)
setOnPreferenceChangeListener { _, newValue ->
try {
val setting = preferences.edit().putString(EXTRA_SOURCES_COUNT_KEY, newValue as String).commit()
Toast.makeText(screen.context, "Restart Aniyomi to apply new setting.", Toast.LENGTH_LONG).show()
setting
} catch (e: Exception) {
e.printStackTrace()
false
}
}
}.also(screen::addPreference)
}
EditTextPreference(screen.context).apply {
key = PREF_CUSTOM_LABEL_KEY
title = "Custom Label"
summary = "Show the given label for the source instead of the default."
setDefaultValue(PREF_CUSTOM_LABEL_DEFAULT)
setOnPreferenceChangeListener { _, newValue ->
runCatching {
val value = (newValue as String).trim().ifBlank { PREF_CUSTOM_LABEL_DEFAULT }
Toast.makeText(screen.context, "Restart Aniyomi to apply new setting.", Toast.LENGTH_LONG).show()
preferences.edit().putString(key, value).commit()
}.getOrDefault(false)
}
}.also(screen::addPreference)
val mediaLibPref = medialibPreference(screen)
screen.addPreference(
screen.editTextPreference(
HOSTURL_KEY,
"Host URL",
HOSTURL_DEFAULT,
baseUrl,
false,
"",
mediaLibPref,
),
)
screen.addPreference(
screen.editTextPreference(
USERNAME_KEY,
"Username",
USERNAME_DEFAULT,
username,
false,
"The account username",
mediaLibPref,
),
)
screen.addPreference(
screen.editTextPreference(
PASSWORD_KEY,
"Password",
PASSWORD_DEFAULT,
password,
true,
"••••••••",
mediaLibPref,
),
)
screen.addPreference(mediaLibPref)
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 ->
val selected = newValue as String
val index = findIndexOfValue(selected)
val entry = entryValues[index] as String
preferences.edit().putString(key, entry).commit()
}
}.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 ->
val selected = newValue as String
val index = findIndexOfValue(selected)
val entry = entryValues[index] as String
preferences.edit().putString(key, entry).commit()
}
}.also(screen::addPreference)
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(PREF_INFO_DEFAULT)
setOnPreferenceChangeListener { _, newValue ->
val new = newValue as Boolean
preferences.edit().putBoolean(key, new).commit()
}
}.also(screen::addPreference)
SwitchPreferenceCompat(screen.context).apply {
key = PREF_TRUST_CERT_KEY
title = "Trust all certificates"
summary = "Requires app restart to take effect."
setDefaultValue(PREF_TRUST_CERT_DEFAULT)
setOnPreferenceChangeListener { _, newValue ->
val new = newValue as Boolean
preferences.edit().putBoolean(key, new).commit()
}
}.also(screen::addPreference)
SwitchPreferenceCompat(screen.context).apply {
key = PREF_SPLIT_COLLECTIONS_KEY
title = "Split collections"
summary = "Split each item in a collection into its own entry"
setDefaultValue(PREF_SPLIT_COLLECTIONS_DEFAULT)
setOnPreferenceChangeListener { _, newValue ->
val new = newValue as Boolean
preferences.edit().putBoolean(key, new).commit()
}
}.also(screen::addPreference)
SwitchPreferenceCompat(screen.context).apply {
key = PREF_SORT_EPISODES_KEY
title = "Sort episodes by release date"
summary = "Useful for collections, otherwise items in a collection are grouped by name."
setDefaultValue(PREF_SORT_EPISODES_DEFAULT)
setOnPreferenceChangeListener { _, newValue ->
val new = newValue as Boolean
preferences.edit().putBoolean(key, new).commit()
}
}.also(screen::addPreference)
}
private val SharedPreferences.getApiKey
get() = getString(APIKEY_KEY, null)
private val SharedPreferences.getUserId
get() = getString(USERID_KEY, null)
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 val SharedPreferences.getSplitCol
get() = getBoolean(PREF_SPLIT_COLLECTIONS_KEY, PREF_SPLIT_COLLECTIONS_DEFAULT)
private val SharedPreferences.sortEp
get() = getBoolean(PREF_SORT_EPISODES_KEY, PREF_SORT_EPISODES_DEFAULT)
private abstract class MediaLibPreference(context: Context) : ListPreference(context) {
abstract fun reload()
}
private fun medialibPreference(screen: PreferenceScreen) =
object : MediaLibPreference(screen.context) {
override fun reload() {
this.apply {
key = MEDIALIB_KEY
title = "Select Media Library"
summary = "%s"
Thread {
try {
val mediaLibsResponse = client.newCall(
GET("$baseUrl/Users/$userId/Items?api_key=$apiKey"),
).execute()
val mediaJson = mediaLibsResponse.parseAs<ItemsDto>().items
val entriesArray = mediaJson.map { it.name }
val entriesValueArray = mediaJson.map { it.id }
entries = entriesArray.toTypedArray()
entryValues = entriesValueArray.toTypedArray()
} catch (ex: Exception) {
entries = emptyArray()
entryValues = emptyArray()
}
}.start()
setOnPreferenceChangeListener { _, newValue ->
val selected = newValue as String
val index = findIndexOfValue(selected)
val entry = entryValues[index] as String
parentId = entry
preferences.edit().putString(key, entry).commit()
}
}
}
}.apply { reload() }
private fun getSummary(isPassword: Boolean, value: String, placeholder: String) = when {
isPassword && value.isNotEmpty() || !isPassword && value.isEmpty() -> placeholder
else -> value
}
private fun PreferenceScreen.editTextPreference(key: String, title: String, default: String, value: String, isPassword: Boolean = false, placeholder: String, mediaLibPref: MediaLibPreference): EditTextPreference {
return EditTextPreference(context).apply {
this.key = key
this.title = title
summary = getSummary(isPassword, value, placeholder)
this.setDefaultValue(default)
dialogTitle = title
setOnBindEditTextListener {
it.inputType = if (isPassword) {
InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
} else {
InputType.TYPE_CLASS_TEXT
}
}
setOnPreferenceChangeListener { _, newValue ->
try {
val newValueString = newValue as String
val res = preferences.edit().putString(key, newValueString).commit()
summary = getSummary(isPassword, newValueString, placeholder)
val loginRes = login(true, context)
if (loginRes == true) {
mediaLibPref.reload()
}
res
} catch (e: Exception) {
false
}
}
}
}
}

View file

@ -0,0 +1,94 @@
package eu.kanade.tachiyomi.animeextension.all.jellyfin
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
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import okhttp3.Headers
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody
import uy.kohesive.injekt.injectLazy
class JellyfinAuthenticator(
private val preferences: SharedPreferences,
private val baseUrl: String,
private val client: OkHttpClient,
) {
private val json: Json by injectLazy()
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
saveLogin(key, userId)
Pair(key, userId)
}.getOrElse {
Log.e(LOG_TAG, it.stackTraceToString())
Pair(null, null)
}
}
private fun authenticateWithPassword(username: String, password: String): LoginDto {
var deviceId = getPrefDeviceId()
if (deviceId.isNullOrEmpty()) {
deviceId = getRandomString()
setPrefDeviceId(deviceId)
}
val aniyomiVersion = AppInfo.getVersionName()
val androidVersion = Build.VERSION.RELEASE
val authHeader = Headers.headersOf(
"X-Emby-Authorization",
"MediaBrowser Client=\"$CLIENT\", Device=\"Android $androidVersion\", DeviceId=\"$deviceId\", Version=\"$aniyomiVersion\"",
)
val body = json.encodeToString(
buildJsonObject {
put("Username", username)
put("Pw", password)
},
).toRequestBody("application/json; charset=utf-8".toMediaType())
val request = POST("$baseUrl/Users/authenticatebyname", headers = authHeader, body = body)
return client.newCall(request).execute().parseAs()
}
private fun getRandomString(): String {
val allowedChars = ('A'..'Z') + ('a'..'z') + ('0'..'9')
return (1..172)
.map { allowedChars.random() }
.joinToString("")
}
private fun saveLogin(key: String, userId: String) {
preferences.edit()
.putString(APIKEY_KEY, key)
.putString(USERID_KEY, userId)
.apply()
}
private fun getPrefDeviceId(): String? = preferences.getString(
DEVICEID_KEY,
null,
)
private fun setPrefDeviceId(value: String) = preferences.edit().putString(
DEVICEID_KEY,
value,
).apply()
companion object {
private const val DEVICEID_KEY = "device_id"
private const val CLIENT = "Aniyomi"
private const val LOG_TAG = "JellyfinAuthenticator"
}
}

View file

@ -0,0 +1,129 @@
package eu.kanade.tachiyomi.animeextension.all.jellyfin
object JellyfinConstants {
val QUALITIES_LIST = arrayOf(
Quality(480, 360, 292000, 128000, "360p - 420 kbps"),
Quality(854, 480, 528000, 192000, "480p - 720 kbps"),
Quality(854, 480, 1308000, 192000, "480p - 1.5 Mbps"),
Quality(854, 480, 2808000, 192000, "480p - 3 Mbps"),
Quality(1280, 720, 3808000, 192000, "720p - 4 Mbps"),
Quality(1280, 720, 5808000, 192000, "720p - 6 Mbps"),
Quality(1280, 720, 7808000, 192000, "720p - 8 Mbps"),
Quality(1920, 1080, 9808000, 192000, "1080p - 10 Mbps"),
Quality(1920, 1080, 14808000, 192000, "1080p - 15 Mbps"),
Quality(1920, 1080, 19808000, 192000, "1080p - 20 Mbps"),
Quality(1920, 1080, 39808000, 192000, "1080p - 40 Mbps"),
Quality(1920, 1080, 59808000, 192000, "1080p - 60 Mbps"),
Quality(3840, 2160, 80000000, 192000, "4K - 80 Mbps"),
Quality(3840, 2160, 120000000, 192000, "4K - 120 Mbps"),
)
data class Quality(
val width: Int,
val height: Int,
val videoBitrate: Int,
val audioBitrate: Int,
val description: String,
)
val PREF_VALUES = arrayOf(
"aar", "abk", "ace", "ach", "ada", "ady", "afh", "afr", "ain", "aka", "akk", "ale", "alt", "amh", "ang", "anp", "apa",
"ara", "arc", "arg", "arn", "arp", "arw", "asm", "ast", "ath", "ava", "ave", "awa", "aym", "aze", "bai", "bak", "bal",
"bam", "ban", "bas", "bej", "bel", "bem", "ben", "ber", "bho", "bik", "bin", "bis", "bla", "bod", "bos", "bra", "bre",
"bua", "bug", "bul", "byn", "cad", "car", "cat", "ceb", "ces", "cha", "chb", "che", "chg", "chk", "chm", "chn", "cho",
"chp", "chr", "chu", "chv", "chy", "cnr", "cop", "cor", "cos", "cre", "crh", "csb", "cym", "dak", "dan", "dar", "del",
"den", "deu", "dgr", "din", "div", "doi", "dsb", "dua", "dum", "dyu", "dzo", "efi", "egy", "eka", "ell", "elx", "eng",
"enm", "epo", "est", "eus", "ewe", "ewo", "fan", "fao", "fas", "fat", "fij", "fil", "fin", "fiu", "fon", "fra", "frm",
"fro", "frr", "frs", "fry", "ful", "fur", "gaa", "gay", "gba", "gez", "gil", "gla", "gle", "glg", "glv", "gmh", "goh",
"gon", "gor", "got", "grb", "grc", "grn", "gsw", "guj", "gwi", "hai", "hat", "hau", "haw", "heb", "her", "hil", "hin",
"hit", "hmn", "hmo", "hrv", "hsb", "hun", "hup", "hye", "iba", "ibo", "ido", "iii", "ijo", "iku", "ile", "ilo", "ina",
"inc", "ind", "inh", "ipk", "isl", "ita", "jav", "jbo", "jpn", "jpr", "jrb", "kaa", "kab", "kac", "kal", "kam", "kan",
"kar", "kas", "kat", "kau", "kaw", "kaz", "kbd", "kha", "khm", "kho", "kik", "kin", "kir", "kmb", "kok", "kom", "kon",
"kor", "kos", "kpe", "krc", "krl", "kru", "kua", "kum", "kur", "kut", "lad", "lah", "lam", "lao", "lat", "lav", "lez",
"lim", "lin", "lit", "lol", "loz", "ltz", "lua", "lub", "lug", "lui", "lun", "luo", "lus", "mad", "mag", "mah", "mai",
"mak", "mal", "man", "mar", "mas", "mdf", "mdr", "men", "mga", "mic", "min", "mkd", "mkh", "mlg", "mlt", "mnc", "mni",
"moh", "mon", "mos", "mri", "msa", "mus", "mwl", "mwr", "mya", "myv", "nah", "nap", "nau", "nav", "nbl", "nde", "ndo",
"nds", "nep", "new", "nia", "nic", "niu", "nld", "nno", "nob", "nog", "non", "nor", "nqo", "nso", "nub", "nwc", "nya",
"nym", "nyn", "nyo", "nzi", "oci", "oji", "ori", "orm", "osa", "oss", "ota", "oto", "pag", "pal", "pam", "pan", "pap",
"pau", "peo", "phn", "pli", "pol", "pon", "por", "pro", "pus", "que", "raj", "rap", "rar", "roh", "rom", "ron", "run",
"rup", "rus", "sad", "sag", "sah", "sam", "san", "sas", "sat", "scn", "sco", "sel", "sga", "shn", "sid", "sin", "slk",
"slv", "sma", "sme", "smj", "smn", "smo", "sms", "sna", "snd", "snk", "sog", "som", "son", "sot", "spa", "sqi", "srd",
"srn", "srp", "srr", "ssw", "suk", "sun", "sus", "sux", "swa", "swe", "syc", "syr", "tah", "tai", "tam", "tat", "tel",
"tem", "ter", "tet", "tgk", "tgl", "tha", "tig", "tir", "tiv", "tkl", "tlh", "tli", "tmh", "tog", "ton", "tpi", "tsi",
"tsn", "tso", "tuk", "tum", "tup", "tur", "tvl", "twi", "tyv", "udm", "uga", "uig", "ukr", "umb", "urd", "uzb", "vai",
"ven", "vie", "vol", "vot", "wal", "war", "was", "wen", "wln", "wol", "xal", "xho", "yao", "yap", "yid", "yor", "zap",
"zbl", "zen", "zgh", "zha", "zho", "zul", "zun", "zza",
)
val PREF_ENTRIES = arrayOf(
"Qafaraf; Afar Af; Afaraf; Qafar af", "Аҧсуа бызшәа Aƥsua bızšwa; Аҧсшәа Aƥsua", "بهسا اچيه", "Lwo", "Dangme",
"Адыгабзэ; Кӏахыбзэ", "El-Afrihili", "Afrikaans", "アイヌ・イタㇰ Ainu-itak", "Akan", "𒀝𒅗𒁺𒌑", "Уна́ӈам тунуу́; Унаӈан умсуу",
"Алтай тили", "አማርኛ Amârıñâ", "Ænglisc; Anglisc; Englisc", "Angika", "Apache languages", "العَرَبِيَّة al'Arabiyyeẗ",
"Official Aramaic (700300 BCE); Imperial Aramaic (700300 BCE)", "aragonés", "Mapudungun; Mapuche", "Hinónoʼeitíít",
"Lokono", "অসমীয়া", "Asturianu; Llïonés", "Athapascan languages", "Магӏарул мацӏ; Авар мацӏ", "Avestan", "अवधी",
"Aymar aru", "Azərbaycan dili; آذربایجان دیلی; Азәрбајҹан дили", "Bamiléké", "Башҡорт теле; Başqort tele",
"بلوچی", "ߓߊߡߊߣߊߣߞߊߣ", "ᬪᬵᬱᬩᬮᬶ; ᬩᬲᬩᬮᬶ; Basa Bali", "Mbene; Ɓasaá", "Bidhaawyeet", "Беларуская мова Belaruskaâ mova",
"Chibemba", "বাংলা Bāŋlā", "Tamaziɣt; Tamazight; ⵜⴰⵎⴰⵣⵉⵖⵜ; ⵝⴰⵎⴰⵣⵉⵗⵝ; ⵜⴰⵎⴰⵣⵉⵗⵜ", "भोजपुरी", "Bikol", "Ẹ̀dó",
"Bislama", "ᓱᖽᐧᖿ", "བོད་སྐད་ Bodskad; ལྷ་སའི་སྐད་ Lhas'iskad", "bosanski", "Braj", "Brezhoneg", "буряад хэлэн",
"ᨅᨔ ᨕᨘᨁᨗ", "български език bălgarski ezik", "ብሊና; ብሊን", "Hasí:nay", "Kari'nja", "català,valencià", "Sinugbuanong Binisayâ",
"čeština; český jazyk", "Finu' Chamoru", "Muysccubun", "Нохчийн мотт; نَاخچیین موٓتت; ნახჩიე მუოთთ", "جغتای",
"Chuukese", "марий йылме", "chinuk wawa; wawa; chinook lelang; lelang", "Chahta'", "ᑌᓀᓱᒼᕄᓀ (Dënesųłiné)",
"ᏣᎳᎩ ᎦᏬᏂᎯᏍᏗ Tsalagi gawonihisdi", "Славе́нскїй ѧ҆зы́къ", "Чӑвашла", "Tsėhésenėstsestȯtse", "crnogorski / црногорски",
"ϯⲙⲉⲑⲣⲉⲙⲛ̀ⲭⲏⲙⲓ; ⲧⲙⲛ̄ⲧⲣⲙ̄ⲛ̄ⲕⲏⲙⲉ", "Kernowek", "Corsu; Lingua corsa", "Cree", "Къырымтатарджа; Къырымтатар тили; Ҡырымтатарҗа; Ҡырымтатар тили",
"Kaszëbsczi jãzëk", "Cymraeg; y Gymraeg", "Dakhótiyapi; Dakȟótiyapi", "dansk", "дарган мез", "Delaware", "Dene K'e",
"Deutsch", "Dogrib", "Thuɔŋjäŋ", "ދިވެހި; ދިވެހިބަސް Divehi", "𑠖𑠵𑠌𑠤𑠮; डोगरी; ڈوگرى", "Dolnoserbski; Dolnoserbšćina",
"Duala", "Dutch, Middle (ca. 10501350)", "Julakan", "རྫོང་ཁ་ Ĵoŋkha", "Efik", "Egyptian (Ancient)", "Ekajuk",
"Νέα Ελληνικά Néa Ellêniká", "Elamite", "English", "English, Middle (11001500)", "Esperanto", "eesti keel",
"euskara", "Èʋegbe", "Ewondo", "Fang", "føroyskt", "فارسی Fārsiy", "Mfantse; Fante; Fanti", "Na Vosa Vakaviti",
"Wikang Filipino", "suomen kieli", "Finno-Ugrian languages", "Fon gbè", "français", "françois; franceis", "Franceis; François; Romanz",
"Frasch; Fresk; Freesk; Friisk", "Oostfreesk; Plattdüütsk", "Frysk", "Fulfulde; Pulaar; Pular", "Furlan",
"", "Basa Gayo", "Gbaya", "ግዕዝ", "Taetae ni Kiribati", "Gàidhlig", "Gaeilge", "galego", "Gaelg; Gailck", "Diutsch",
"Diutisk", "Gondi", "Bahasa Hulontalo", "Gothic", "Grebo", "Ἑλληνική", "Avañe'ẽ", "Schwiizerdütsch", "ગુજરાતી Gujarātī",
"Dinjii Zhu Ginjik", "X̱aat Kíl; X̱aadas Kíl; X̱aayda Kil; Xaad kil", "kreyòl ayisyen", "Harshen Hausa; هَرْشَن",
"ʻŌlelo Hawaiʻi", "עברית 'Ivriyþ", "Otjiherero", "Ilonggo", "हिन्दी Hindī", "𒉈𒅆𒇷", "lus Hmoob; lug Moob; lol Hmongb; 𖬇𖬰𖬞 𖬌𖬣𖬵",
"Hiri Motu", "hrvatski", "hornjoserbšćina", "magyar nyelv", "Na:tinixwe Mixine:whe'", "Հայերէն Hayerèn; Հայերեն Hayeren",
"Jaku Iban", "Asụsụ Igbo", "Ido", "ꆈꌠꉙ Nuosuhxop", "Ịjọ", "ᐃᓄᒃᑎᑐᑦ Inuktitut", "Interlingue; Occidental", "Pagsasao nga Ilokano; Ilokano",
"Interlingua (International Auxiliary Language Association)", "Indo-Aryan languages", "bahasa Indonesia",
"ГӀалгӀай мотт", "Iñupiaq", "íslenska", "italiano; lingua italiana", "ꦧꦱꦗꦮ / Basa Jawa", "la .lojban.", "日本語 Nihongo",
"Dzhidi", "عربية يهودية / ערבית יהודית", "Qaraqalpaq tili; Қарақалпақ тили", "Tamaziɣt Taqbaylit; Tazwawt",
"Jingpho", "Kalaallisut; Greenlandic", "Kamba", "ಕನ್ನಡ Kannađa", "Karen languages", "कॉशुर / كأشُر", "ქართული Kharthuli",
"Kanuri", "ꦧꦱꦗꦮ", "қазақ тілі qazaq tili; қазақша qazaqşa", "Адыгэбзэ (Къэбэрдейбзэ) Adıgăbză (Qăbărdeĭbză)",
"কা কতিয়েন খাশি", "ភាសាខ្មែរ Phiəsaakhmær", "Khotanese; Sakan", "Gĩkũyũ", "Ikinyarwanda", "кыргызча kırgızça; кыргыз тили kırgız tili",
"Kimbundu", "कोंकणी", "Коми кыв", "Kongo", "한국어 Han'gug'ô", "Kosraean", "Kpɛlɛwoo", "Къарачай-Малкъар тил; Таулу тил",
"karjal; kariela; karjala", "कुड़ुख़", "Kuanyama; Kwanyama", "къумукъ тил/qumuq til", "kurdî / کوردی", "Kutenai",
"Judeo-español", "بھارت کا", "Lamba", "ພາສາລາວ Phasalaw", "Lingua latīna", "Latviešu valoda", "Лезги чӏал",
"Lèmburgs", "Lingala", "lietuvių kalba", "Lomongo", "Lozi", "Lëtzebuergesch", "Cilubà / Tshiluba", "Kiluba",
"Luganda", "Cham'teela", "Chilunda", "Dholuo", "Mizo ṭawng", "Madhura", "मगही", "Kajin M̧ajeļ", "मैथिली; মৈথিলী",
"Basa Mangkasara' / ᨅᨔ ᨆᨀᨔᨑ", "മലയാളം Malayāļã", "Mandi'nka kango", "मराठी Marāţhī", "ɔl", "мокшень кяль",
"Mandar", "Mɛnde yia", "Gaoidhealg", "Míkmawísimk", "Baso Minang", "македонски јазик makedonski jazik", "Mon-Khmer languages",
"Malagasy", "Malti", "ᠮᠠᠨᠵᡠ ᡤᡳᠰᡠᠨ Manju gisun", "Manipuri", "Kanienkéha", "монгол хэл mongol xel; ᠮᠣᠩᠭᠣᠯ ᠬᠡᠯᠡ",
"Mooré", "Te Reo Māori", "Bahasa Melayu", "Mvskoke", "mirandés; lhéngua mirandesa", "मारवाड़ी", "မြန်မာစာ Mrãmācā; မြန်မာစကား Mrãmākā:",
"эрзянь кель", "Nahuatl languages", "napulitano", "dorerin Naoero", "Diné bizaad; Naabeehó bizaad", "isiNdebele seSewula",
"siNdebele saseNyakatho", "ndonga", "Plattdütsch; Plattdüütsch", "नेपाली भाषा Nepālī bhāśā", "नेपाल भाषा; नेवाः भाय्",
"Li Niha", "Niger-Kordofanian languages", "ko e vagahau Niuē", "Nederlands; Vlaams", "norsk nynorsk", "norsk bokmål",
"Ногай тили", "Dǫnsk tunga; Norrœnt mál", "norsk", "N'Ko", "Sesotho sa Leboa", "لغات نوبية", "पुलां भाय्; पुलाङु नेपाल भाय्",
"Chichewa; Chinyanja", "Nyamwezi", "Nyankole", "Runyoro", "Nzima", "occitan; lenga d'òc", "Ojibwa", "ଓଡ଼ିଆ",
"Afaan Oromoo", "Wazhazhe ie / 𐓏𐓘𐓻𐓘𐓻𐓟 𐒻𐓟", "Ирон ӕвзаг Iron ævzag", "لسان عثمانى / lisân-ı Osmânî", "Otomian languages",
"Salitan Pangasinan", "Pārsīk; Pārsīg", "Amánung Kapampangan; Amánung Sísuan", "ਪੰਜਾਬੀ / پنجابی Pãjābī",
"Papiamentu", "a tekoi er a Belau", "Persian, Old (ca. 600400 B.C.)", "𐤃𐤁𐤓𐤉𐤌 𐤊𐤍𐤏𐤍𐤉𐤌 Dabariym Kana'aniym",
"Pāli", "Język polski", "Pohnpeian", "português", "Provençal, Old (to 1500); Old Occitan (to 1500)", "پښتو Pax̌tow",
"Runa simi; kichwa simi; Nuna shimi", "राजस्थानी", "Vananga rapa nui", "Māori Kūki 'Āirani", "Rumantsch; Rumàntsch; Romauntsch; Romontsch",
"romani čhib", "limba română", "Ikirundi", "armãneashce; armãneashti; rrãmãneshti", "русский язык russkiĭ âzık",
"Sandaweeki", "yângâ tî sängö", "Сахалыы", "ארמית", "संस्कृतम् Sąskŕtam; 𑌸𑌂𑌸𑍍𑌕𑍃𑌤𑌮𑍍", "Sasak", "ᱥᱟᱱᱛᱟᱲᱤ", "Sicilianu",
"Braid Scots; Lallans", "Selkup", "Goídelc", "ၵႂၢမ်းတႆးယႂ်", "Sidaamu Afoo", "සිංහල Sĩhala", "slovenčina; slovenský jazyk",
"slovenski jezik; slovenščina", "Åarjelsaemien gïele", "davvisámegiella", "julevsámegiella", "anarâškielâ",
"Gagana faʻa Sāmoa", "sääʹmǩiõll", "chiShona", "سنڌي / सिन्धी / ਸਿੰਧੀ", "Sooninkanxanne", "Sogdian", "af Soomaali",
"Songhai languages", "Sesotho [southern]", "español; castellano", "Shqip", "sardu; limba sarda; lingua sarda",
"Sranan Tongo", "српски / srpski", "Seereer", "siSwati", "Kɪsukuma", "ᮘᮞ ᮞᮥᮔ᮪ᮓ / Basa Sunda", "Sosoxui", "𒅴𒂠",
"Kiswahili", "svenska", "Classical Syriac", "ܠܫܢܐ ܣܘܪܝܝܐ Lešānā Suryāyā", "Reo Tahiti; Reo Mā'ohi", "ภาษาไท; ภาษาไต",
"தமிழ் Tamił", "татар теле / tatar tele / تاتار", "తెలుగు Telugu", "KʌThemnɛ", "Terêna", "Lia-Tetun", "тоҷикӣ toçikī",
"Wikang Tagalog", "ภาษาไทย Phasathay", "ትግረ; ትግሬ; ኻሳ; ትግራይት", "ትግርኛ", "Tiv", "Tokelau", "Klingon; tlhIngan-Hol",
"Lingít", "Tamashek", "chiTonga", "lea faka-Tonga", "Tok Pisin", "Tsimshian", "Setswana", "Xitsonga", "Türkmençe / Түркменче / تورکمن تیلی تورکمنچ; türkmen dili / түркмен дили",
"chiTumbuka", "Tupi languages", "Türkçe", "Te Ggana Tuuvalu; Te Gagana Tuuvalu", "Twi", "тыва дыл", "удмурт кыл",
"Ugaritic", "ئۇيغۇرچە ; ئۇيغۇر تىلى", "Українська мова; Українська", "Úmbúndú", "اُردُو Urduw", "Oʻzbekcha / Ózbekça / ўзбекча / ئوزبېچه; oʻzbek tili / ўзбек тили / ئوبېک تیلی",
"ꕙꔤ", "Tshivenḓa", "Tiếng Việt", "Volapük", "vađđa ceeli", "Wolaitta; Wolaytta", "Winaray; Samareño; Lineyte-Samarnon; Binisayâ nga Winaray; Binisayâ nga Samar-Leyte; “Binisayâ nga Waray”",
"wá:šiw ʔítlu", "Serbsce / Serbski", "Walon", "Wolof", "Хальмг келн / Xaľmg keln", "isiXhosa", "Yao", "Yapese",
"ייִדיש; יידיש; אידיש Yidiš", "èdè Yorùbá", "Diidxazá/Dizhsa", "Blissymbols; Blissymbolics; Bliss", "Tuḍḍungiyya",
"ⵜⴰⵎⴰⵣⵉⵖⵜ ⵜⴰⵏⴰⵡⴰⵢⵜ", "Vahcuengh / 話僮", "中文 Zhōngwén; 汉语; 漢語 Hànyǔ", "isiZulu", "Shiwi'ma", "kirmanckî; dimilkî; kirdkî; zazakî",
)
}

View file

@ -0,0 +1,234 @@
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,
@SerialName("SeasonName") val seasonName: 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
}
"BoxSet" -> {
httpUrl.fragment("boxSet")
title = name
}
"Series" -> {
httpUrl.fragment("series")
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 }
if (type == "Movie") {
status = SAnime.COMPLETED
} else {
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,
prefix: String,
): SEpisode = SEpisode.create().apply {
when (epType) {
EpisodeType.MOVIE -> {
episode_number = 1F
name = "${prefix}Movie"
}
EpisodeType.EPISODE -> {
episode_number = indexNumber?.toFloat() ?: 1F
name = "${prefix}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,
)
}

View file

@ -0,0 +1,18 @@
package eu.kanade.tachiyomi.animeextension.all.jellyfin
import eu.kanade.tachiyomi.animesource.AnimeSource
import eu.kanade.tachiyomi.animesource.AnimeSourceFactory
class JellyfinFactory : AnimeSourceFactory {
override fun createSources(): List<AnimeSource> {
val firstJelly = Jellyfin("1")
val extraCount = firstJelly.preferences.getString(Jellyfin.EXTRA_SOURCES_COUNT_KEY, Jellyfin.EXTRA_SOURCES_COUNT_DEFAULT)!!.toInt()
return buildList(extraCount) {
add(firstJelly)
for (i in 2..extraCount) {
add(Jellyfin("$i"))
}
}
}
}