Add extension: Jellyfin (#1076)

Co-authored-by: jmir1 <jhmiramon@gmail.com>
This commit is contained in:
Secozzi
2022-12-14 23:10:47 +01:00
committed by GitHub
parent 230a33d56c
commit ee422ae8f5
11 changed files with 869 additions and 0 deletions

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="eu.kanade.tachiyomi.animeextension" />

View File

@ -0,0 +1,16 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
ext {
extName = 'Jellyfin'
pkgNameSuffix = 'all.jellyfin'
extClass = '.Jellyfin'
extVersionCode = 1
libVersion = '13'
}
dependencies {
compileOnly libs.bundles.coroutines
}
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,168 @@
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"
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,603 @@
package eu.kanade.tachiyomi.animeextension.all.jellyfin
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.PreferenceScreen
import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource
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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.boolean
import kotlinx.serialization.json.float
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.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 kotlin.math.ceil
import kotlin.math.floor
class Jellyfin : ConfigurableAnimeSource, AnimeHttpSource() {
override val name = "Jellyfin"
override val lang = "all"
override val supportsLatest = true
override val client: OkHttpClient = network.cloudflareClient
private val preferences: SharedPreferences by lazy {
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
}
override var baseUrl = JFConstants.getPrefHostUrl(preferences)
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)
init {
login(false)
}
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)
if (username.isEmpty() || password.isEmpty()) {
return null
}
val (newKey, newUid) = runBlocking {
withContext(Dispatchers.IO) {
JellyfinAuthenticator(preferences, baseUrl, 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 Anime (is currently sorted by name instead of e.g. ratings)
override fun popularAnimeRequest(page: Int): Request {
if (parentId.isEmpty()) {
throw Exception("Select library in the extension settings.")
}
val startIndex = (page - 1) * 20
val url = "$baseUrl/Users/$userId/Items".toHttpUrl().newBuilder()
url.addQueryParameter("api_key", apiKey)
url.addQueryParameter("StartIndex", startIndex.toString())
url.addQueryParameter("Limit", "20")
url.addQueryParameter("Recursive", "true")
url.addQueryParameter("SortBy", "SortName")
url.addQueryParameter("SortOrder", "Ascending")
url.addQueryParameter("includeItemTypes", "Movie,Series,Season")
url.addQueryParameter("ImageTypeLimit", "1")
url.addQueryParameter("ParentId", parentId)
url.addQueryParameter("EnableImageTypes", "Primary")
return GET(url.toString())
}
override fun popularAnimeParse(response: Response): AnimesPage {
val (list, hasNext) = animeParse(response)
return AnimesPage(
list.sortedBy { it.title },
hasNext,
)
}
// Episodes
override fun episodeListParse(response: Response): List<SEpisode> {
val json = Json.decodeFromString<JsonObject>(response.body!!.string())
val episodeList = mutableListOf<SEpisode>()
// Is movie
if (json.containsKey("Type")) {
val episode = SEpisode.create()
val id = json["Id"]!!.jsonPrimitive.content
episode.episode_number = 1.0F
episode.name = json["Name"]!!.jsonPrimitive.content
episode.setUrlWithoutDomain("/Users/$userId/Items/$id?api_key=$apiKey")
episodeList.add(episode)
} else {
val items = json["Items"]!!.jsonArray
for (item in items) {
val episode = SEpisode.create()
val jsonObj = item.jsonObject
val id = jsonObj["Id"]!!.jsonPrimitive.content
val epNum = if (jsonObj["IndexNumber"] == null) {
null
} else {
jsonObj["IndexNumber"]!!.jsonPrimitive.float
}
if (epNum != null) {
episode.episode_number = epNum
val formattedEpNum = if (floor(epNum) == ceil(epNum)) {
epNum.toInt().toString()
} else {
epNum.toString()
}
episode.name = "Episode $formattedEpNum: " + jsonObj["Name"]!!.jsonPrimitive.content
} else {
episode.episode_number = 0F
episode.name = jsonObj["Name"]!!.jsonPrimitive.content
}
episode.setUrlWithoutDomain("/Users/$userId/Items/$id?api_key=$apiKey")
episodeList.add(episode)
}
}
return episodeList.reversed()
}
private fun animeParse(response: Response): AnimesPage {
val items = Json.decodeFromString<JsonObject>(response.body!!.string())["Items"]?.jsonArray
val animesList = mutableListOf<SAnime>()
if (items != null) {
for (item in items) {
val anime = SAnime.create()
val jsonObj = item.jsonObject
if (jsonObj["Type"]!!.jsonPrimitive.content == "Season") {
val seasonId = jsonObj["Id"]!!.jsonPrimitive.content
val seriesId = jsonObj["SeriesId"]!!.jsonPrimitive.content
anime.setUrlWithoutDomain("/Shows/$seriesId/Episodes?api_key=$apiKey&SeasonId=$seasonId")
// Virtual if show doesn't have any sub-folders, i.e. no seasons
if (jsonObj["LocationType"]!!.jsonPrimitive.content == "Virtual") {
anime.title = jsonObj["SeriesName"]!!.jsonPrimitive.content
anime.thumbnail_url = "$baseUrl/Items/$seriesId/Images/Primary?api_key=$apiKey"
} else {
anime.title = jsonObj["SeriesName"]!!.jsonPrimitive.content + " " + jsonObj["Name"]!!.jsonPrimitive.content
anime.thumbnail_url = "$baseUrl/Items/$seasonId/Images/Primary?api_key=$apiKey"
}
// If season doesn't have image, fallback to series image
if (jsonObj["ImageTags"].toString() == "{}") {
anime.thumbnail_url = "$baseUrl/Items/$seriesId/Images/Primary?api_key=$apiKey"
}
} else if (jsonObj["Type"]!!.jsonPrimitive.content == "Movie") {
val id = jsonObj["Id"]!!.jsonPrimitive.content
anime.title = jsonObj["Name"]!!.jsonPrimitive.content
anime.thumbnail_url = "$baseUrl/Items/$id/Images/Primary?api_key=$apiKey"
anime.setUrlWithoutDomain("/Users/$userId/Items/$id?api_key=$apiKey")
} else {
continue
}
animesList.add(anime)
}
}
val hasNextPage = (items?.size?.compareTo(20) ?: -1) >= 0
return AnimesPage(animesList, hasNextPage)
}
// Video urls
override fun videoListParse(response: Response): List<Video> {
val videoList = mutableListOf<Video>()
val item = Json.decodeFromString<JsonObject>(response.body!!.string())
val id = item["Id"]!!.jsonPrimitive.content
val sessionResponse = client.newCall(
GET(
"$baseUrl/Items/$id/PlaybackInfo?userId=$userId&api_key=$apiKey"
)
).execute()
val sessionJson = Json.decodeFromString<JsonObject>(sessionResponse.body!!.string())
val sessionId = sessionJson["PlaySessionId"]!!.jsonPrimitive.content
val mediaStreams = sessionJson["MediaSources"]!!.jsonArray[0].jsonObject["MediaStreams"]?.jsonArray
val subtitleList = mutableListOf<Track>()
val prefSub = preferences.getString(JFConstants.PREF_SUB_KEY, "eng")!!
val prefAudio = preferences.getString(JFConstants.PREF_AUDIO_KEY, "jpn")!!
var audioIndex = 1
var subIndex: Int? = null
var width = 1920
var height = 1080
// Get subtitle streams and audio index
if (mediaStreams != null) {
for (media in mediaStreams) {
val index = media.jsonObject["Index"]!!.jsonPrimitive.int
val codec = media.jsonObject["Codec"]!!.jsonPrimitive.content
val lang = media.jsonObject["Language"]
val supportsExternalStream = media.jsonObject["SupportsExternalStream"]!!.jsonPrimitive.boolean
val type = media.jsonObject["Type"]!!.jsonPrimitive.content
if (type == "Subtitle" && supportsExternalStream) {
val subUrl = "$baseUrl/Videos/$id/$id/Subtitles/$index/0/Stream.$codec?api_key=$apiKey"
// TODO: add ttf files in media attachment (if possible)
val title = media.jsonObject["DisplayTitle"]!!.jsonPrimitive.content
if (lang != null) {
if (lang.jsonPrimitive.content == prefSub) {
subtitleList.add(
0,
Track(subUrl, title)
)
} else {
subtitleList.add(
Track(subUrl, title)
)
}
} else {
subtitleList.add(
Track(subUrl, title)
)
}
} else if (type == "Subtitle") {
if (lang != null) {
if (lang.jsonPrimitive.content == prefSub) {
subIndex = index
}
}
}
if (type == "Audio") {
if (lang != null) {
if (lang.jsonPrimitive.content == prefAudio) {
audioIndex = index
}
}
}
if (media.jsonObject["Type"]!!.jsonPrimitive.content == "Video") {
width = media.jsonObject["Width"]!!.jsonPrimitive.int
height = media.jsonObject["Height"]!!.jsonPrimitive.int
}
}
}
// Loop over qualities
for (quality in JFConstants.QUALITIES_LIST) {
if (width < quality.width && height < quality.height) {
val url = "$baseUrl/Videos/$id/stream?static=True&api_key=$apiKey"
videoList.add(Video(url, "Best", url))
return videoList.reversed()
} else {
val url = "$baseUrl/videos/$id/main.m3u8".toHttpUrl().newBuilder()
url.addQueryParameter("api_key", apiKey)
url.addQueryParameter("VideoCodec", "h264")
url.addQueryParameter("AudioCodec", "aac,mp3")
url.addQueryParameter("AudioStreamIndex", audioIndex.toString())
subIndex?.let { url.addQueryParameter("SubtitleStreamIndex", it.toString()) }
url.addQueryParameter("VideoCodec", "h264")
url.addQueryParameter("VideoCodec", "h264")
url.addQueryParameter(
"VideoBitrate", quality.videoBitrate.toString()
)
url.addQueryParameter(
"AudioBitrate", quality.audioBitrate.toString()
)
url.addQueryParameter("PlaySessionId", sessionId)
url.addQueryParameter("TranscodingMaxAudioChannels", "6")
url.addQueryParameter("RequireAvc", "false")
url.addQueryParameter("SegmentContainer", "ts")
url.addQueryParameter("MinSegments", "1")
url.addQueryParameter("BreakOnNonKeyFrames", "true")
url.addQueryParameter("h264-profile", "high,main,baseline,constrainedbaseline")
url.addQueryParameter("h264-level", "51")
url.addQueryParameter("h264-deinterlace", "true")
url.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, "Best", url))
return videoList.reversed()
}
// search
override fun searchAnimeParse(response: Response) = animeParse(response)
override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request {
if (parentId.isEmpty()) {
throw Exception("Select library in the extension settings.")
}
if (query.isBlank()) {
throw Exception("Search query blank")
}
val startIndex = (page - 1) * 20
val url = "$baseUrl/Users/$userId/Items".toHttpUrl().newBuilder()
url.addQueryParameter("api_key", apiKey)
url.addQueryParameter("StartIndex", startIndex.toString())
url.addQueryParameter("Limit", "20")
url.addQueryParameter("Recursive", "true")
url.addQueryParameter("SortBy", "SortName")
url.addQueryParameter("SortOrder", "Ascending")
url.addQueryParameter("includeItemTypes", "Movie,Series,Season")
url.addQueryParameter("ImageTypeLimit", "1")
url.addQueryParameter("EnableImageTypes", "Primary")
url.addQueryParameter("ParentId", parentId)
url.addQueryParameter("SearchTerm", query)
return GET(url.toString())
}
// Details
override fun animeDetailsRequest(anime: SAnime): Request {
val infoArr = anime.url.split("/").toTypedArray()
val id = if (infoArr[1] == "Users") {
infoArr[4].split("?").toTypedArray()[0]
} else {
infoArr[2]
}
val url = "$baseUrl/Users/$userId/Items/$id".toHttpUrl().newBuilder()
url.addQueryParameter("api_key", apiKey)
url.addQueryParameter("fields", "Studios")
return GET(url.toString())
}
override fun animeDetailsParse(response: Response): SAnime {
val item = response.body?.let { Json.decodeFromString<JsonObject>(it.string()) }!!.jsonObject
val anime = SAnime.create()
anime.author = if (item["Studios"]!!.jsonArray.isEmpty()) {
""
} else {
item["Studios"]!!.jsonArray[0].jsonObject["Name"]!!.jsonPrimitive.content
}
anime.description = item["Overview"]?.let {
Jsoup.parse(it.jsonPrimitive.content.replace("<br>", "br2n")).text().replace("br2n", "\n")
} ?: ""
if (item["Genres"]!!.jsonArray.isEmpty()) {
anime.genre = ""
} else {
val genres = mutableListOf<String>()
for (genre in item["Genres"]!!.jsonArray) {
genres.add(genre.jsonPrimitive.content)
}
anime.genre = genres.joinToString()
}
anime.status = item["Status"]?.let {
if (it.jsonPrimitive.content == "Ended") SAnime.COMPLETED else SAnime.COMPLETED
} ?: SAnime.UNKNOWN
return anime
}
// Latest
override fun latestUpdatesRequest(page: Int): Request {
if (parentId.isEmpty()) {
throw Exception("Select library in the extension settings.")
}
val startIndex = (page - 1) * 20
val url = "$baseUrl/Users/$userId/Items".toHttpUrl().newBuilder()
url.addQueryParameter("api_key", apiKey)
url.addQueryParameter("StartIndex", startIndex.toString())
url.addQueryParameter("Limit", "20")
url.addQueryParameter("Recursive", "true")
url.addQueryParameter("SortBy", "DateCreated,SortName")
url.addQueryParameter("SortOrder", "Descending")
url.addQueryParameter("includeItemTypes", "Movie,Series,Season")
url.addQueryParameter("ImageTypeLimit", "1")
url.addQueryParameter("ParentId", parentId)
url.addQueryParameter("EnableImageTypes", "Primary")
return GET(url.toString())
}
override fun latestUpdatesParse(response: Response) = animeParse(response)
// Filters - not used
// settings
override fun setupPreferenceScreen(screen: PreferenceScreen) {
val mediaLibPref = medialibPreference(screen)
screen.addPreference(
screen.editTextPreference(
JFConstants.HOSTURL_KEY, JFConstants.HOSTURL_TITLE, JFConstants.HOSTURL_DEFAULT, baseUrl, false, "", mediaLibPref
)
)
screen.addPreference(
screen.editTextPreference(
JFConstants.USERNAME_KEY, JFConstants.USERNAME_TITLE, "", username, false, "", mediaLibPref
)
)
screen.addPreference(
screen.editTextPreference(
JFConstants.PASSWORD_KEY, JFConstants.PASSWORD_TITLE, "", password, true, "••••••••", mediaLibPref
)
)
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")
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()
}
}
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")
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()
}
}
screen.addPreference(audioLangPref)
}
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 = JFConstants.MEDIALIB_KEY
title = JFConstants.MEDIALIB_TITLE
summary = "%s"
Thread {
try {
val mediaLibsResponse = client.newCall(
GET("$baseUrl/Users/$userId/Items?api_key=$apiKey")
).execute()
val mediaJson = mediaLibsResponse.body?.let { Json.decodeFromString<JsonObject>(it.string()) }?.get("Items")?.jsonArray
val entriesArray = mutableListOf<String>()
val entriesValueArray = mutableListOf<String>()
if (mediaJson != null) {
for (media in mediaJson) {
entriesArray.add(media.jsonObject["Name"]!!.jsonPrimitive.content)
entriesValueArray.add(media.jsonObject["Id"]!!.jsonPrimitive.content)
}
}
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 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 = if ((isPassword && value.isNotEmpty()) || (!isPassword && value.isEmpty())) {
placeholder
} else {
value
}
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 = if ((isPassword && newValueString.isNotEmpty()) || (!isPassword && newValueString.isEmpty())) {
placeholder
} else {
newValueString
}
val loginRes = login(true, context)
if (loginRes == true) {
mediaLibPref.reload()
}
res
} catch (e: Exception) {
false
}
}
}
}
}

View File

@ -0,0 +1,80 @@
package eu.kanade.tachiyomi.animeextension.all.jellyfin
import android.content.SharedPreferences
import android.os.Build
import eu.kanade.tachiyomi.AppInfo
import eu.kanade.tachiyomi.network.POST
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.Headers
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody
class JellyfinAuthenticator(
private val preferences: SharedPreferences,
private val baseUrl: String,
private val client: OkHttpClient,
) {
fun login(username: String, password: String): Pair<String?, String?> {
return try {
val authResult = authenticateWithPassword(username, password)
?: throw Exception()
val key = authResult["AccessToken"]!!.jsonPrimitive.content
val userId = authResult["SessionInfo"]!!.jsonObject["UserId"]!!.jsonPrimitive.content
saveLogin(key, userId)
Pair(key, userId)
} catch (e: Exception) {
Pair(null, null)
}
}
private fun authenticateWithPassword(username: String, password: String): JsonObject? {
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 = """
{"Username":"$username","Pw":"$password"}
""".trimIndent()
.toRequestBody("application/json".toMediaType())
val request = POST("$baseUrl/Users/authenticatebyname", headers = authHeader, body = body)
val response = client.newCall(request).execute().body?.string()
return response?.let { Json.decodeFromString<JsonObject>(it) }
}
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(JFConstants.APIKEY_KEY, key)
.putString(JFConstants.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()
}
private const val DEVICEID_KEY = "device_id"
private const val CLIENT = "Aniyomi"