From 9c512ea3ac54498f230a1e5ebff004425bdcfec9 Mon Sep 17 00:00:00 2001 From: Draff Date: Mon, 22 Jan 2024 23:39:22 +0000 Subject: [PATCH] re add kogma balls, lanraragi and kavita --- src/all/kavita/AndroidManifest.xml | 2 + src/all/kavita/CHANGELOG.md | 93 ++ src/all/kavita/README.md | 37 + .../kavita/assets/i18n/messages_en.properties | 18 + .../assets/i18n/messages_es_es.properties | 18 + .../assets/i18n/messages_fr_fr.properties | 20 + .../assets/i18n/messages_nb_no.properties | 21 + src/all/kavita/build.gradle | 12 + .../kavita/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 3175 bytes .../kavita/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 1883 bytes .../kavita/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 3949 bytes .../kavita/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 6506 bytes .../kavita/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 9189 bytes .../tachiyomi/extension/all/kavita/Filters.kt | 112 ++ .../tachiyomi/extension/all/kavita/Kavita.kt | 1264 +++++++++++++++++ .../extension/all/kavita/KavitaConstants.kt | 81 ++ .../extension/all/kavita/KavitaFactory.kt | 13 + .../extension/all/kavita/KavitaHelper.kt | 141 ++ .../extension/all/kavita/KavitaInt.kt | 17 + .../extension/all/kavita/dto/FilterDto.kt | 124 ++ .../extension/all/kavita/dto/MangaDto.kt | 103 ++ .../extension/all/kavita/dto/MetadataDto.kt | 104 ++ .../extension/all/kavita/dto/Responses.kt | 28 + src/all/komga/AndroidManifest.xml | 2 + src/all/komga/CHANGELOG.md | 383 +++++ src/all/komga/README.md | 35 + src/all/komga/build.gradle | 7 + src/all/komga/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 6039 bytes src/all/komga/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 3108 bytes .../komga/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 8143 bytes .../komga/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 16331 bytes .../komga/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 22992 bytes .../tachiyomi/extension/all/komga/Komga.kt | 637 +++++++++ .../extension/all/komga/KomgaFactory.kt | 14 + .../extension/all/komga/KomgaHelper.kt | 14 + .../tachiyomi/extension/all/komga/dto/Dto.kt | 132 ++ .../extension/all/komga/dto/PageWrapperDto.kt | 16 + src/all/lanraragi/AndroidManifest.xml | 2 + src/all/lanraragi/CHANGELOG.md | 79 ++ src/all/lanraragi/README.md | 35 + src/all/lanraragi/build.gradle | 7 + .../lanraragi/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 4793 bytes .../lanraragi/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 2509 bytes .../res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 7589 bytes .../res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 14460 bytes .../res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 23207 bytes .../extension/all/lanraragi/LANmodels.kt | 31 + .../extension/all/lanraragi/LANraragi.kt | 472 ++++++ .../all/lanraragi/LANraragiFactory.kt | 13 + 49 files changed, 4087 insertions(+) create mode 100644 src/all/kavita/AndroidManifest.xml create mode 100644 src/all/kavita/CHANGELOG.md create mode 100644 src/all/kavita/README.md create mode 100644 src/all/kavita/assets/i18n/messages_en.properties create mode 100644 src/all/kavita/assets/i18n/messages_es_es.properties create mode 100644 src/all/kavita/assets/i18n/messages_fr_fr.properties create mode 100644 src/all/kavita/assets/i18n/messages_nb_no.properties create mode 100644 src/all/kavita/build.gradle create mode 100644 src/all/kavita/res/mipmap-hdpi/ic_launcher.png create mode 100644 src/all/kavita/res/mipmap-mdpi/ic_launcher.png create mode 100644 src/all/kavita/res/mipmap-xhdpi/ic_launcher.png create mode 100644 src/all/kavita/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 src/all/kavita/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/Filters.kt create mode 100644 src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/Kavita.kt create mode 100644 src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/KavitaConstants.kt create mode 100644 src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/KavitaFactory.kt create mode 100644 src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/KavitaHelper.kt create mode 100644 src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/KavitaInt.kt create mode 100644 src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/dto/FilterDto.kt create mode 100644 src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/dto/MangaDto.kt create mode 100644 src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/dto/MetadataDto.kt create mode 100644 src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/dto/Responses.kt create mode 100644 src/all/komga/AndroidManifest.xml create mode 100644 src/all/komga/CHANGELOG.md create mode 100644 src/all/komga/README.md create mode 100644 src/all/komga/build.gradle create mode 100755 src/all/komga/res/mipmap-hdpi/ic_launcher.png create mode 100755 src/all/komga/res/mipmap-mdpi/ic_launcher.png create mode 100755 src/all/komga/res/mipmap-xhdpi/ic_launcher.png create mode 100755 src/all/komga/res/mipmap-xxhdpi/ic_launcher.png create mode 100755 src/all/komga/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 src/all/komga/src/eu/kanade/tachiyomi/extension/all/komga/Komga.kt create mode 100644 src/all/komga/src/eu/kanade/tachiyomi/extension/all/komga/KomgaFactory.kt create mode 100644 src/all/komga/src/eu/kanade/tachiyomi/extension/all/komga/KomgaHelper.kt create mode 100644 src/all/komga/src/eu/kanade/tachiyomi/extension/all/komga/dto/Dto.kt create mode 100644 src/all/komga/src/eu/kanade/tachiyomi/extension/all/komga/dto/PageWrapperDto.kt create mode 100644 src/all/lanraragi/AndroidManifest.xml create mode 100644 src/all/lanraragi/CHANGELOG.md create mode 100644 src/all/lanraragi/README.md create mode 100644 src/all/lanraragi/build.gradle create mode 100644 src/all/lanraragi/res/mipmap-hdpi/ic_launcher.png create mode 100644 src/all/lanraragi/res/mipmap-mdpi/ic_launcher.png create mode 100644 src/all/lanraragi/res/mipmap-xhdpi/ic_launcher.png create mode 100644 src/all/lanraragi/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 src/all/lanraragi/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 src/all/lanraragi/src/eu/kanade/tachiyomi/extension/all/lanraragi/LANmodels.kt create mode 100644 src/all/lanraragi/src/eu/kanade/tachiyomi/extension/all/lanraragi/LANraragi.kt create mode 100644 src/all/lanraragi/src/eu/kanade/tachiyomi/extension/all/lanraragi/LANraragiFactory.kt diff --git a/src/all/kavita/AndroidManifest.xml b/src/all/kavita/AndroidManifest.xml new file mode 100644 index 000000000..8072ee00d --- /dev/null +++ b/src/all/kavita/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/src/all/kavita/CHANGELOG.md b/src/all/kavita/CHANGELOG.md new file mode 100644 index 000000000..2843891e9 --- /dev/null +++ b/src/all/kavita/CHANGELOG.md @@ -0,0 +1,93 @@ +## 1.3.13 + +### Fixed + + * Fixed 'null cannot be cast to non-null type' exception + +## 1.3.12 + +### Features + +* Migrate filters to v2 +* Implemented smartFilters +* Added localization support + +### Fixed + +* Fixed publication status not showing + +## 1.3.10 + +### Features + +* API Change for Kavita v0.7.2 + +## 1.3.9 + +### Features + +* Added pdf support + +## 1.3.8 + +### Fix + +* Fixed `Expected URL scheme 'http' or 'https` when downloading + +## 1.3.7 + +### Features + +* New Sort filter: Time to read +* New Filter: Year release filter + +### Fix + +* Filters can now be used together with search +* Epub and pdfs no longer show in format filter (currently not supported) + +## 1.3.6 + +### Fix + +* Fixed "lateinit property title not initialized" + +## 1.3.5 + +### Features + +* Ignore DOH +* Added sort option `Item Added` +* Latest button now shows latest `Item Added` + +## 1.3.4 + +### Features + +* Exclude from bulk update warnings + +## 1.2.3 + +### Fix + +* Fixed Rating filter +* Fixed Chapter list not sorting correctly +* Fixed search +* Fixed manga details not showing correctly +* Fixed filters not populating if account was not admin + +### Features +* The extension is now ready to implement tracking. +* Min required version for the extension to work properly: `v0.5.1.1` + +## 1.2.2 + +### Features + +* Add `CHANGELOG.md` & `README.md` + +## 1.2.1 + +### Features + +* first version diff --git a/src/all/kavita/README.md b/src/all/kavita/README.md new file mode 100644 index 000000000..79f467edd --- /dev/null +++ b/src/all/kavita/README.md @@ -0,0 +1,37 @@ +# Kavita + +Table of Content +- [FAQ](#FAQ) + - [Why do I see no manga?](#why-do-i-see-no-manga) + - [Where can I get more information about Kavita?](#where-can-i-get-more-information-about-kavita) + - [The Kavita extension stopped working?](#the-kavita-extension-stopped-working) + - [Can I add more than one Kavita server or user?](#can-i-add-more-than-one-kavita-server-or-user) + - [Can I test the Kavita extension before setting up my own server?](#can-i-test-the-kavita-extension-before-setting-up-my-own-server) +- [Guides](#Guides) + - [How do I add my Kavita server to Tachiyomi?](#how-do-i-add-my-kavita-server-to-tachiyomi) + +Don't find the question you are look for go check out our general FAQs and Guides over at [Extension FAQ](https://tachiyomi.org/help/faq/#extensions) or [Getting Started](https://tachiyomi.org/help/guides/getting-started/#installation) + +Kavita also has a documentation about the Tachiyomi Kavita extension at the [Kavita wiki](https://wiki.kavitareader.com/en/guides/misc/tachiyomi). + +## FAQ + +### Why do I see no manga? +Kavita is a self-hosted comic/manga media server. + +### Where can I get more information about Kavita? +You can visit the [Kavita](https://www.kavitareader.com/) website for for more information. + +### The Kavita extension stopped working? +Make sure that your Kavita server and extension are on the newest version. + +### Can I add more than one Kavita server or user? +Yes, currently you can add up to 3 different Kavita instances to Tachiyomi. + +### Can I test the Kavita extension before setting up my own server? +Yes, you can try it out with the DEMO servers OPDS url `https://demo.kavitareader.com/api/opds/aca1c50d-7e08-4f37-b356-aecd6bf69b72`. + +## Guides + +### How do I add my Kavita server to Tachiyomi? +Go into the settings of the Kavita extension from the Extension tab in Browse and fill in your OPDS url. diff --git a/src/all/kavita/assets/i18n/messages_en.properties b/src/all/kavita/assets/i18n/messages_en.properties new file mode 100644 index 000000000..52f341d65 --- /dev/null +++ b/src/all/kavita/assets/i18n/messages_en.properties @@ -0,0 +1,18 @@ +login_errors_failed_login=Login failed. Something went wrong +login_errors_header_token_empty="Error: The JSON Web Token is empty.\nTry opening the extension first." +login_errors_invalid_url=Invalid URL: +login_errors_parse_tokendto=There was an error parsing the auth token +pref_customsource_title=Displayed name for source +pref_edit_customsource_summary=Here you can change this source name.\nYou can write a descriptive name to identify this OPDS URL. +pref_filters_summary=Show these filters in the filter list +pref_filters_title=Default filters shown +pref_opds_badformed_url=Incorrect OPDS address. Please copy it from User settings \u2192 3rd party apps \u2192 OPDS URL +pref_opds_duplicated_source_url=The URL is configured in a different source -> +pref_opds_must_setup_address=You must set up the address to communicate with Kavita +pref_opds_summary=The OPDS URL copied from User Settings. This should include address and end with the API key. +restartapp_settings=Restart Tachiyomi to apply new setting. +version_exceptions_chapters_parse=Unhandled exception parsing chapters. Send your logs to the Kavita devs. +check_version=Ensure you have the newest version of the extension and Kavita. (0.7.8 or newer.)\nIf the issue persists, report it to the Kavita developers with the accompanying logs. +version_exceptions_smart_filter=Could not decode SmartFilter. Ensure you are using Kavita version 0.7.11 or later. +http_errors_500=Something went wrong +http_errors_401=There was an error logging in. Try again or reload the app diff --git a/src/all/kavita/assets/i18n/messages_es_es.properties b/src/all/kavita/assets/i18n/messages_es_es.properties new file mode 100644 index 000000000..8f6d3058c --- /dev/null +++ b/src/all/kavita/assets/i18n/messages_es_es.properties @@ -0,0 +1,18 @@ +pref_customsource_title=Nombre de la instancia +pref_edit_customsource_summary=Aqui puedes cambiar el nombre de la instancia.\nPuedes escribir un nombre descriptivo que identifique esta url/instancia +restartapp_settings=Reinicia la aplicación para aplicar los cambios +version_exceptions_chapters_parse=Algo ha ido mal al procesar los capitulos. Envia los registros de fallo a los desarrolladores de Kavita +check_version=Comprueba que tienes tanto Kavita como la extension actualizada. (Version minima: 0.7.8)\nSi el problema persiste, reportalo a los desarrolladores de Kavita aportando los registros de fallo. +version_exceptions_smart_filter=Fallo al decodificar los filtros inteligentes. Aseg\u00FArate que estas al menos en la version 0.7.11 de Kavita +http_errors_500=Algo ha ido mal +http_errors_401=Ha habido un error al iniciar sesi\u00F3n. Prueba otra vez o reinicia la aplicaci\u00F3n +pref_opds_summary=La url del OPDS copiada de la configuraci\u00F3n del usuario. Debe incluir la direcci\u00F3n y la clave api al final. +pref_filters_summary=Mostrar estos filtros en la lista de filtros +pref_filters_title=Filtros por defecto +pref_opds_badformed_url=La direcci\u00F3n OPDS no es correcta. Por favor, c\u00F3piela desde la Configuraci\u00F3n de usuario-> aplicaciones de terceros -> url de OPDS +login_errors_parse_tokendto=Se ha producido un error al procesar el token de autenticaci\u00F3n +pref_opds_duplicated_source_url=Url est\u00E1 configurado en una fuente diferente -> +pref_opds_must_setup_address=Debe configurar la direcci\u00F3n para comunicarse con Kavita +login_errors_failed_login=Error en el inicio de sesi\u00F3n. Algo ha ido mal +login_errors_header_token_empty="Error: el token jwt est\u00E1 vac\u00EDo.\nIntente abrir primero la extensi\u00F3n" +login_errors_invalid_url=URL no v\u00E1lida: diff --git a/src/all/kavita/assets/i18n/messages_fr_fr.properties b/src/all/kavita/assets/i18n/messages_fr_fr.properties new file mode 100644 index 000000000..bd29b962a --- /dev/null +++ b/src/all/kavita/assets/i18n/messages_fr_fr.properties @@ -0,0 +1,20 @@ + + +version_exceptions_chapters_parse=Exception non trait\u00E9e durant l'analyse des chapitres. Envoyez les journaux aux d\u00E9velopeurs de Kavita +pref_customsource_title=Nom d'affichage pour la source +version_exceptions_smart_filter=\u00C9chec du d\u00E9codage de SmartFilter. Assurez-vous que vous utilisez au moins Kavita version 0.7.11 +pref_opds_summary=L'URL OPDS a \u00E9t\u00E9 copi\u00E9e \u00E0 partir des param\u00E8tres de l'utilisateur. Ceci devrait inclure l'adresse et la cl\u00E9 API. +pref_filters_summary=Afficher ces filtres dans la liste des filtres +check_version=Assurez-vous que vous avez l'extension et Kavita mises \u00E0 jour. (version Mini\u202F: 0.7.8)\nSi le probl\u00E8me persiste, signalez-le aux d\u00E9veloppeurs de Kavita en fournissant des journaux +pref_filters_title=Filtres par d\u00E9faut affich\u00E9s +pref_edit_customsource_summary=Ici vous pouvez changer ce nom source.\nVous pouvez \u00E9crire un nom descriptif pour identifier cette URL opds +pref_opds_badformed_url=L'adresse OPDS n'est pas correcte. Veuillez la copiez \u00E0 partir des param\u00E8tres de l'utilisateur - > Applis tierces -> URL OPDS +login_errors_parse_tokendto=Il y a eu une erreur pendant l'analyse du jeton d'authentification +restartapp_settings=Red\u00E9marrez Tachiyomi pour appliquer le nouveau r\u00E9glage. +pref_opds_duplicated_source_url=L'URL est configur\u00E9e dans une autre source -> +pref_opds_must_setup_address=Vous devez configurer l'adresse pour communiquer avec Kavita +login_errors_failed_login=\u00C9chec de la connexion. Quelque chose s'est mal pass\u00E9 +http_errors_500=Quelque chose s'est mal pass\u00E9 +login_errors_header_token_empty=\u00AB\u00A0Erreur\u202F: le jeton jwt est vide.\nEssayez d'abord d'ouvrir l'extension\u00A0\u00BB +login_errors_invalid_url=URL invalide\u202F: +http_errors_401=Il y a eu une erreur. Essayez de nouveau ou rechargez l'application diff --git a/src/all/kavita/assets/i18n/messages_nb_no.properties b/src/all/kavita/assets/i18n/messages_nb_no.properties new file mode 100644 index 000000000..c2e0a530d --- /dev/null +++ b/src/all/kavita/assets/i18n/messages_nb_no.properties @@ -0,0 +1,21 @@ + + +pref_customsource_title=Vist kildenavn +pref_edit_customsource_summary=Her kan du endre dette kildenavnet.\nDu kan skrive et beskrivende navn for \u00E5 identifisere denne OPDS-nettadressen. +restartapp_settings=Ny innstilling trer i kraft n\u00E5r du starter Tachiyomi p\u00E5 ny. +duplicated_source_url=Nettadressen er satt opp i en annen Kavita-instans +pref_filters_summary=Vis disse filterne i filterlisten +pref_filters_title=Forvalgte filtre valgt +login_errors_parse_tokendto=Kunne ikke tolke identifiseringssymbolet +login_errors_failed_login=Innlogging mislyktes. Noe gikk galt. +http_errors_500=Noe gikk galt +login_errors_header_token_empty="Feil: JSON-nettsymbol er tomt.\nPr\u00F8v \u00E5 \u00E5pne utvidelsen f\u00F8rst." +login_errors_invalid_url=Ugyldig nettadresse: +version_exceptions_chapters_parse=Uh\u00E5ndtert unntak i tolking av kapitler. Send loggene dine til Kavita-utviklerne. +version_exceptions_smart_filter=Kunne ikke dekode smartfilter. Forsikre deg om at du bruker Kavita versjon 0.7.11 eller nyere. +pref_opds_summary=OPDS-nettadressen kopiert fra brukerinnstillingene. Denne skal inkludere med adressen og slutte med API-n\u00F8kkelen. +check_version=Forsikre deg om at b\u00E5de utvidelsen og Kavita er av nyeste versjon. (Ihvertfall 0.7.8)\nHvis problemet vedvarer kan du rapportere det til Kavita-utviklerne med tilh\u00F8rende loggf\u00F8ring. +pref_opds_badformed_url=OPDS-adressen er ikke riktig. Kopier den fra brukerinnstillinger -> tredjepartsprogrammer -> OPDS-nettadresse +pref_opds_duplicated_source_url=Nettadressen er satt opp i en annen instans -> +pref_opds_must_setup_address=Du m\u00E5 sette opp adressen som skal kommunisere med Kavita +http_errors_401=Feil med innlogging. Pr\u00F8v \u00E5 laste inn p\u00E5 ny, eller start programmet p\u00E5 ny. diff --git a/src/all/kavita/build.gradle b/src/all/kavita/build.gradle new file mode 100644 index 000000000..bb2376766 --- /dev/null +++ b/src/all/kavita/build.gradle @@ -0,0 +1,12 @@ +ext { + extName = 'Kavita' + extClass = '.KavitaFactory' + extVersionCode = 13 +} + +dependencies { + implementation 'info.debatty:java-string-similarity:2.0.0' + implementation(project(':lib:i18n')) +} + +apply from: "$rootDir/common.gradle" diff --git a/src/all/kavita/res/mipmap-hdpi/ic_launcher.png b/src/all/kavita/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..16cee6c3e562ab2df5129ed30737a02362d999de GIT binary patch literal 3175 zcmV-t44CtYP)0;V=?Q?z1@rOLzVA3hSBD5bX2m>?2^MPtC) z*4ifiK~ZBtqXjHb0;CUYE2f~WX&TzJw%Vi=fnK0KmxHTKEVX00EH-I3eXgw=A6isf?BK~0(!YawHzky zzfDK6*iFCqcGv91i)W4U)RD{#eo?rs_L3{4!jH45hM_=8wf7L6$ zJSbIC5TN9j4nDXhHa+{W6^r#v2#$}OSUo;9P5=-;Zz82NOrUcC4t~r!H};K#D>v@L zb1EVg$pF#!>D5Ob+>q$aZ9+H+nFJ?8J%MdH9r3{S2sYUKJriw#p$}i&a%9E#?!{)y zHS6RCD+3hAbF#M_x^Hv3ulu_)IR379W>3=TFw;_jBQr1m_in#M=H>oY9v-{z>6mI2c6 zSvF-#5rhVO|Z2Y$qUD zu2Z*tFV6pKK%&?X1jzrJN)j2>|JWu|_mJ{r(lJ><9@n&qr3Fq}=o_%3$(RP6i4KTk zf*uW!1!Yr*f>B|qWJf#!87;}$5g&3NR5Giqow|xSfb9Sjq?*C*_rupebTxw%1sGnHaQqg-Gg0TNTFThei?QmpsMAhH29lLJv8a%p%D!8KbTmU7XtDxovJ1nZx+BD)nW1M0l4E+ zH$itM!v?qGR27cr?c_9t6z-Xmf@uj0#tP#Isoqc=>2-Ie86?~BT2Lxgpj^>a_&_$3 zf{v7Jj*q{10)BJsSzNN45TBcaShw6vTMtMup=Q1UmB-J4HC%-yi{`?e-&_UVU0DX` z5d`!C2k1u#s2@oY9NuJc{t-@H1q3LXg2)_gmU|C855NB1VbF2apxrnfwE3M<$jt*f zfF{;2Ffp2Cr*vTsBcMNFNhNRCfW8$DNY{NCJq|9RX*Dw;IEH~{sA^&e*Hs?_wDT}j z(Nq(kMnDU@+6&0i5FhOb(60~> zMKPwJt589LVAvu++cu~B#jAODcm${_0krFM6*opciY?}pi^pd=Pv%DTsVVrU}i zj)ccaSE@Sb7ywj`R7!(Hg4WmQgZ3k!oi-qi0qSf>7x;;8QbCP?{C?U6WR2*shYgbS5P=08V>z5l<$OV00o4L&7{u<89;t0^g#1U zVh-f0Ph?_~3rJM%aC%K+XMmh2#si`#Hd-i!`yp(E9BfawH;NUC6ve74d?1_c@I*1c ziM17wC{YvvVk`2{;nh!I&(&}RvMAT{D~Y}PpL3ivw-$3w5cQ{d6GH_B4YZ~p*TmM@ zVcswdT$EznN5RIZ;3ZnDjw{xKGU<9fXb%E%*J5m`vCv*>$i~nVpwCdEC^0d<+)>?a zFIJg}xq#3?m&?^~6GP=th^Il)zbrT+RLY?$73otcHR-V&3X5grhv=vS!c5vNcLbDf zTMkH6?hpZr5`|pz@<%aM6D0+@hYN^L6l>KqfyuW7P>?raYcW(1WzwXe_3KtM6RVPw z9xY;xIeeH2R8DV?C=XI_(#WP0oir)XRnU&#I*H*;1xt!xN0U&rsX}?mN~n6JvSMSXpV1l;lw!UtGBw7P(3E%wpDIdI$8 zZiZYg6PXX&ok%Bv%BOiWO;rc&xe)?7^yCZhhuzPxeX&afh}(~F%N>u|CN_WGZ202J zW#|)d>p3DK%4O)f6jlcFqXp)}yoW-Ic*>vu@(+08&|mRcI;*{aWRhn~4F?ca1-m*k z!iNsz!=qhPjAD50!ubO*aOD+nfE>q`mtfK&WsEBh?Qs0 zwnh71qi77YH`Ks^>L5s|8@HshxX$91?EI-RK(Q3osju&Y_>7Lg$7q@g^UECtMJrs$ zolnESaNo>$w+#AILtwp!1%4L-(Zen3_$PWHG1zSbva7MRBiti-gNdwHO#!-b-%oI7 zRs8Tm6dx=b46FHI#%-KJNJoYMF3>Iro{js|@g+Tw_;?S*a0%kyI#z?D_VCpu0TeU< zkx4L6V>%dbmvAS~fcPwQ^fOWrBNL0Hnkds|pW9YIVKW<;U6W7Z9g$A}VQoi8(?&pH zosY8YknX=tlt9RJEmu$&4C+r5Aa4IfU{vnaEVrfsbwqH|69L133i9rDz-ihCeUhRW zuEoM0$89AT8<^}+3*D-!qJEFRmw({s%IHtIQr6LV!(%_(ke%7T0UvN;74eCJ@J1r> z@CEs@WSnZ@TL}`rpGzaWQ z)U&o7wVMX7o*;!`>z@N;8p8aZ_mw~c5O3&f->=T`5ICG zi)YUN?c~D^eE`P`Gc?6mT#<2aq- zL~kW0Skb)@oYQIj%)c$WJ2xvbN3_vf*1457;G2s_PQClv!Eei=EiwSL=j@>ahN`SZ z<0H@!L+S=HEN==AAU8f3!)_CX3vkGejP4x#Zo%faacPq`N(NAP<@^`dY;cNl&&#|U zZ@dR1KndrK0GJaM#~D02fAe&l8j6AlK&fe;#6>K-li^G zX^`_tq>NJ1jz@qJ55Nm4s_hu>bFL5NL=)ckQXsfH0!+k9_Yflx0cd3W|K_!40H9q9oWZn4?*@C{?7NpR&<&!d?pw}(S zBURNDWYo0V4{W@q8{5 z!=CNyVfKuvaC*>$!`Dqv(TKfsG{`y@0(b=?olZeEqq^Ts%K{@7KmjeQf{o5uvF1G} z=S;}1oDzX0M01U6w{rS1ia-VSez4J%ka&ojKUR5U zn?iAJ9RMYiMG4jC`W1w1IgvDpUyek;Lotq(LICATEw;LJXlf_|l`2*j$w5*ZH#S#~ z0Ly{40Ejt202W4IpC1A8tYpXSnn~r6k3ifwHkKl=Y!yk!OJbVK?$4BvI07&AeH!iq zbQfHOB{2i2RBK?E^7>9Cl;wb~n^3I{$#XzSL9yWFK>Gp2K7dJV4OHO6U2nNLV50HV zfMiG7twQN~6*B$)AdXeA+)&dK0az8{S5F>+bI}OULM(|Hz&)%wv;~M}Gxh=O!Wwp1 zH+K+pi28u63ZYc;xvUS+>sBGR5BO!YWdNC9T>y%}ZZ87VoLJ`-Z6mL0Sr33BHH0#f z0|4YY!T{NIUKtf_NF0F|g$S@@GF*Ua9e}n6)TE>m!seu66zCvGf`}u?V%PR0gb|=J zx}G8s-TXBLAXXq(3JBol!0t`(C`O=C9g0n24U;&B(x5}9Mplzch|1{LFK)eKf5O5}Sn`sWv*V({jMT~NuG z?FR58K%Ll+z~Y5Z!^7i?@|Ts&DjeTEbs`jtdFUVblN%d;zGlOzA%1=2B&C=~K=qH3!E@{<=t<#Lu5SAT6VDq8AG!>Df4W z6YO8_5)mja44~VPnv73o^xWCd_+e{8 z@&}r@2*kCbM&&f_mHF`IMn|+1fmVzTJk2F-D;5iL5(6wc^KnV`CT+OJB{N*E_r_Y6 zvC_SNn7?J%7h5FM@e6<2|3^wyi%AV0-f%IF&Hp)S>*k#w7rZkI??wL)#J*mqm5a|F z>HR3*-PMyc>h08d+&%AJ{`>Hm*WTZPzu)1Dn9LQL);(d`+-+}c&rj)Eq@+`kznbDa z9$y}lx*|HxF|9k*Yo+fmY&+aLbhUI9N7VRHG61@k$InCfDd2}*a0Ugubz54QN!p_a z32Ohh@q^tl+(P2SNY}}5dL|^}n-^^>=1KzK>zyFznKV)qN6WDG;EkeZ9{?~9{0F3x Ver@jQlf?i4002ovPDHLkV1h5CE(M{uV9T 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 Hu0mjfERi0s literal 0 HcmV?d00001 diff --git a/src/all/kavita/res/mipmap-xxhdpi/ic_launcher.png b/src/all/kavita/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..98f3ea1ff226dc3810f6663eb95098365d4ea1fb GIT binary patch literal 6506 zcmV-w8I|UVP)=NklHZC9)+u|^os#FmFfL%$5 zQ)O^2hZt~T2XJgBp<+w6g{{bmg9NyOu?q=ZxP*{|F0}XV+jr*oo0;C%Gp~EPduHCb zGt}d0cW1hLxniQC}NpV{wDZW>XlV=c2#uG zf(u0F>y?II)!Jp`Ajm79xNPdBre{I}jZ~_Y-&Ur|FO3{M{=NOL{pP@HkAHg%ic*G8 zV32|UMBzz5K@y+a^3;mVX|umL6|a1Bs$A?XRLi7XwG^|e36FV=;Cbf)5fVIlY4Zyx z1Y8jw(X*zPt}`xM7k3DN$JGc-~RJW_rGF8gdq7=BfkK#@EG_0*RwZ~ z*|{f%OZn4F)v_bahQs4Qn~wVED_iFPL`#ZoVZ;*E0V8`PJ~;WnH*eVRBO9WUUx@qy zguV_ck8$UVPp-@?n)lpLDSrm9N_7F!Ql60Dv62zW7?%|7aw;m2NUVb>5DO6f_nc(z z#MHi{>%aHuM}7srD>5ccp#A(``vC~AG2yxF?wjXa@!4CyIUXxrU*R->EpP!V%7e#@#4PkZhP>TU;o#W3`FXV7F^^9Ak*i8PwKXBeQ9lO$)f)Ne<$PS z(+@nI0#KvW$Gm?|;yPe@)}bqL@QEh#yN|5jeD{}MhU*Hp3ZYM=eCPs1)h7u#r0?4L z)Dx4v#$7FgCnN>(Y#?rf^6OWpp}EnQW-uUXWg*}kI&;H?zyAKmA9@VZn)0JUx&Wc? zW1#xLC-a%@&+aHBs^_ye61MVKjOh9&0`nlKasBk`6Vic4bWdTJFVQn4YL`qg_N#xt z`rhl|x=CAw(4nRsj#4G^1CXgc@X3Dam1o|Dn5&28;ujuI+4bt{(tKjIO-~cm$E*X* z`_WM#QQH{R_|Si@y=RFHP#(S!AZ0VA0}!5z3WNZm&y)MZSD!g--ME(DRd?Uo4eLRi zPU!AW-5R4lVF9FLzA#U0)J<32cP4yIzy|?BUx;=vhN?iU|C2?4{@|6T-<9SdIl&7( zebb`fhpUr^FVH#nFN8b<1=0lwi?{iz`xe31I0F#vT$DDit^%3Sm#sj(0MI+q9NGXN z?S|KLxx_x2;NF`Jkjz2=K<7YFad?9&5FO@_Hm_fRa<&5b2FRE4c$h9PbJPg{h2j7B zQ6F1@7TW+(N4g;Z(cWkDfe@e`P@n@+M1@bG_)6Wd+6x{p=5)-LuXH-Vd@%r_2}3`K z_6I3NVmb;G8bEEWJ|3j-${j}dZf4n_4n sh-2g#AJ7sX($=t>wz236D(q+yc~W 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 literal 0 HcmV?d00001 diff --git a/src/all/kavita/res/mipmap-xxxhdpi/ic_launcher.png b/src/all/kavita/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..dc7d8341f3c7731c7f291bce231453c019cf53c3 GIT binary patch literal 9189 zcmb_i_fwP4*M0JkKp>Qa-U&^Ru7DsdL8L2PKzax1f>bFF9TWkjN-ru1f(Qso4Mjju zn)Dj*gVKpoMC$AJe|Ue`yEFIB&g|^YJ#)^TlVqT$Nkhd>1pokzw$@Ffe>>rS3rYSj z8qIrB000)yzNu;wY_n}mnPIK|ye2~T&%?G$v1v$N4LC+Cx~$k!0z##Ham(pM+~22` z-auYe!;e0{?mr?;!5J_l#!f!}B;~3C_w{IDzZAY_anATav_Pz2)Xiu6i)WL6FHUUQ zYJyG{kE^^V+I*@W5AO`MG-Y+P?_OLSS8a5Jf3emBmBpgkxp!JNo6`Y*xG#3?9;H0E zzfWozQUv9x_Z#4xu+Y#W>z`aOPV!I-Fa=OnKCRhH0cgpT#bKIIA$P=7h$fH_RhR|w zVF=eZSyR-}O(6Uh|P zk&fdi`ncJ?=`(-ib3)YCi^g6}TQOr<$`!88U03SF;+d~dqKH0xeV|2r=10S< z=OW&%KW<05m)JI~+mc;?S@=h}U@y`$jB z2o=d9OpXOg{b8;6)AyM#NQ$hw1vAuLLh3h!}{rTlt zwr(8$HOs(*@s4t!^glk<+|WuIn%e!S3bGTf!(HaCX6u{4*1&Yl^Ny3n7Tm{=dO!o* zA<+aQ`?cvg8q$nV>fm{g3p>8pIP{s9$*j(2on*JnE%H;&AiOm|zTD2RW)y%fR_GFh z;m&^QtjB@-7tYK;2SHA)HD{T}LTuVpglco3 zY@X*-1>yZ4O}S0*2Rz`7IC3(l=vZvK#Ech!ex4JAE*2Hr8|z^vI+<>A*@dhkF=bEQ zM^@7L^3S@>~&Mzs%{b16{i4(1XfXMvfVUncu`jhfBdb zbS$;f8r=@EjnElGd?$i}HTk(BA7J2{*D*ZS*ako=x_^130*>a#vz~S8(^8TOw`~_x z?-f@piQ@CD-XCmS696V?=>?q5;06`6tmdhv7(C*1S{zHjkQ^KJ!0$=5;$Sf<7T*6! z9iV!U;o@Nz4d=0D2++)jV+Suapbo;U&T%h;_|Skj5RuHMdI?TSz<)#pU#*O%dzb>| zi<7D{)ZWK|tS?)i49a2gRSg|NL6yHlI|yK_RV}cf4^W}QTD6IWBK>>GYqJ%K{a3Y6 z;}3UIVXz}k=q=K?yI$*qmlw?Bp<1*a>@>+OGA^(|ksrjX^pk^8)x&BdZO4c z_x;9g%gU^oTd(^AzjE0%ZHMQ3;c8=UXa5q@+-4`MEo$9&-Z~geaT5JqHgYbeTRx_t zdFXk_eIjLhxo-Guhh-}QdnLe7qzgk3QuQL9$lxStDQkaKNVl2HZ3iwF^Go%jzHJ>Z z(A8T0QR#gJGa&=^>&hpc;lOhMX-H_O}KeY_gPrC;@SFhx?_T9sB!g61FIKlW}wy@J+-EuSl zJLsB+pDc{;Z5_Rv@RWJXuvpP2y%HNy?WqZ%;EB{>>ZBlgSZ%fcOEAVv;~hrpbhao( z+TB?%4FR5V{s3BTppw6xt(&t5Qxf+uhVKC9k#@SFIRAQ|Cd4730IO*(1}^{B+Q+*Jj_JdAj8i3J%HQ% z^ILOLLOI#G`B2K9Sq|^)*e9lfG+TdCrq0>Y##vkaIZRqOYw2)-59LK=a%DQUyW!S4%KWDz= zpCYghZI^vtYQW4b;5njo+E@!U&Um6}$*5g>5e*$}EJLw@UD3k+($=D8xI#@w>dUGB z?z*2LbxUTbKXSsH%8G%oVR?vL0w?oHr(`gd@(mS}`RBK0$#52L@F*!d_|d#e;O?2eOyraJV6;`m zH#&!P$Ar7Kc=;*vqjzIF{!xrk!-6&lQsxe;>r2HxvOXC~(7X*%xeQ&f?DARSC5YBp31~vr$+wRZX|T2HBn}CK$CQwxb=*-nBJ9Bd zYUQ->;%(#SuX8dKpS~-=m@vjZWZ~sa+?cES|1dnucFiK`hDD zWz!J*NrkUMBi{afQ#&(W71MdmRybtY^gld3OLU}EQJg+54J$y+E=A$y_6Lo!Gd>B5 zM@+lea~1FGz-g4Uq3>9#219YG2E-&xhaR0!O_r=b^zLqflrZG+K&J0E+1Y?0R)B4i zA@p*Z{&Lo`Xj@BTmwQC{S}l|m7b1`pLmB60Y0D>cxds;>lIk=n?DFv1f4Y@qC$ z;dt{eUs<96c9gvx%%FZnr^=nR0gej)=3hJXGzS4?D^fcVbeuE9@#%qdwLs(hNHa@- z>S*jbRJ`Fy53oSE|12pFe+UHdSog^;mjv7a$2Ue_cb?kZ<%kjcr&9J5quc)b(9Dcy zI{HT;fkbi8ZgdSqU~kwHcpp zmI9z|-p&Q=kJjA4n~1gwNzu4j9bdsqV4{X#GGJ(xF6?SuI_~IT>Ey*o={p>rDp}K8 zhgcH4B?qzv(&)BsFtCggY0gZVGMsj9Yag& zVoz^1M}ONId2QZDM%q}*n;ZDrrA@-)lqp{!fu!dZ5;`5(%P;xGAaR1+Jh!UVHKakb z`S}8abE7}=waLGY#*g{aDTwWXVxLS`S-PTwvx30J%bPc&(EE=3X!5VG6I9Dxf7o;3 zkuf4^x0jhOlcbDf6_Q(2ySs~vK2phbe-_8?e(uru9b4jOO7q^5D;RHN)wMY zgllzpi}_0DyAKMl&Kxom$YD;o$$W(7_mYl9i$@Xbfo>C>$mX|=Okk79+WOq=J*k6g zw0YBFAS&HvqA>IQ(_xqsT&qBdxtUGxeSq?9fJrM31CjZiHS;?NLL2zNxo~YX1mXr7 zHpL*SHFYTj20z}L0{k*aBGs`vv)u{Il-800YlYpf(sh;}L)vRhNWDtI$LxHP5{_qiUv~rFKp7P4 zt|!YU$MjQ3Ub_#8Zk&qzECx+!_;6vH5eB2@)2~wMfou4sk@G}zdARLWES}OR_m=7d z^`8b0olGWpdKNx8q7ei4w#mf_-Uj_t7(RTSU-fN>jU!fSTPs$of*-2o*dG>)SIvWl zFQf#3-d|U$Vuk`;96@5B8y08+O+qTXT+kEstD>Xh1F5XkJ(;_+qLEl}je`RiDoS;M!g~V20@9DQ-~0MkH0ZX?wzs$Bvl0 zLNk#Opjd9cCKeh9KKZHOg`=E&4~X1w1Fey;55=6Yl@d2mBwA*4mkM-?vEel4ft}tc zMM7jrNv@({ELKXv_avC!J1#U$J}1ja_vCM2?JhDgMdp*m?vmf3i>b6Lu0j@%*$awI zQW{X*r6@*1=}T-{N{Wj5o-Pa=Wf*_zW+u7pWqbbu;StUsjWZ~e#R40{u01C%u}fzsVxe%d<|j^TStj zq3hmWg7UAqO5VfU*|gF`LvZ*T@HDph#wK8rl9;Z+FRpVF#J+y^sQM(c>Cwe-(Y-qy z%FYs>X}#QGw;CcfV^yos_)^dlja7YH>QQNt2&xA~-RfNWr!jIojo{{V6b|R-V|$K@ z=x?%#lUvB!fL7|i)7y7#x_+9lo*ynzm;WRDq2hYcYCnK^XL;_x7$bPa--%o%5Aag| z*=Ru2v%r|(lhTiPKhS{q78Bdv>_}TY_6>daNF3r3V1$vlY;$JOIBhT4-detRmVhDn zcMi8#M7uB}0sP3%WiFQ^cA>w4YgiT}xydu`g{;F}E0%tM6v6Ea#}gk^@7btoVtv&=sNogV`E5TU<`kDE)Mu?kSrcS(7y%#_YN(zL)dH1mNf3dm z89&!W*JA+3|0xGT!X1R55xHRypRBya^y0DB&>q-nfq%w~rI_V0{xHbWb;^v_11xLk zvJFod>^zh6s8y3$%T&sJZc;>stVTV=L2*fM z<@Nr_1RU`1oi7uEjFlgk;oF~5>ZE#)9;{zRVZVi@0M#XTq+yJHF}=X~P!3%4L6phu zf0G8%7hi4FB%Ui%G%=O&Iw?O*E5j+2aC#;>Sg zZz<=DPf$FBqxqfxr>`{sOuanck@5J+i!(A!L?32`LN zR;3h@-Q1SZ21@K60mwHNtmAfa72cYyQf@Yq*CpEdJ#YL@kqS>v*B^iO-GNo+R4{}Q zHYPOsQ8nPx)Vp-M^pyA~rs)V|1;htP#D4v9gOw4;8h;o3r4Cxx@NIfoi69gdr{{3W zT}Bbx1nYQ=%KFp4D}du^Y#X50h;NiuCZn$!0y6B~1A@pq{(UOIoOGA>nGz;`RK4cT z9ZKj!U!cer!Lr2rzodI!%QCU_>!v*PBjv4dEL(R^HnyP{Szt%eRNRSI1fgZt{r|o* z!bVshQaA{}YnL(mjId}^3>VCz8>b0;5>9)KD9#%4LStX|KjNtr+0pO5qt*Y1t~A|K zP~MGpB!m4*4>&z?K0R5}05GoQmEz+i(8JUTcc-4s(&ldH%BHYara?Y21c7#K?xY@; zkc2q*ZZSgXcp?m5i#V_0gJ{?OUD%nsV`p`XM_?0pKVO{2$r5OJESX0pnDwN%Z=v{z zUf~XCw=+gY`Gfq^;Y!x_GA}Yl$1mm)GgeAEKNVLq=PESN{kR@Y zwaUvp7xSNw^ba_DTmWR%aUAF-K#+f9Ca_xbYiooX#yAPPA>Dl)wAy+Y{s0r$Q(`{52)24`Mt(|X zeo1R8DVg>_v0)yegWR(=dm>MfcB!}05|Sq@Vf+D2sHVGpsssUs=@zo0kOXX#fmL=r zz$}J0iU{}_r|OY69c|;XiyTqPEwSWP=mh1#*mYNb@I_KJ`c+ovT|f_l=~z z@w*h?O?nla<5U^1`g3{y_kRN3(~k%94)4j#Q&Vs(`>N${-6AoW#kb%uJBY^8galz= zc6Q2^D4r(<6ta{ybdvo?efnWonJZb@Or;odYbAmE1e!9uBiB2w+#xEA29VtUQs4T9 zcf7Qq>J++|Dwmb+5tgFz&M|;su|mUcW|MapUj>PWE7933$Q#M8%1VxuEFdMf$y#(- z2kRDHy!-vRz-MzM_e)X+sFsli9O8S|fnqueh0Na7iqLUUDS z^$K%%y#Y?}NxD1FHS11l%m_@UU-m8!WQJDn2z4$_^TArPua?2ojq!AXg%rYG-Fv|I zC^=(zv4?LETpODV&MWVv|aWejZKre9fKTkisFKm(gVe| zO>L#0>E`s7|K;~R4*pWs_Mbg~C+YzDt+KpRt-R|mZ$g7AfR8ed8;ud82hE&M*-`LH zQ;A>Kl#ccNRk?1n=mk#vFv8s}1W-{>6&b}o0ya|>qpc5{cK5DIHGGf5l-wfU^O>~C z?n#0+89w~sky{rGAjg}jXhEVKBF9V8L#HIK6utBbt&O*gHUy5$Q8 zDDAfBqjSW_cubm}XiFguYdZ=5-FhMzmBwm^hV@vchOe>*E+<3*WL{c8$@hLlSb)1q zk#i1J`0PU#h$g4kSVO#o6>ay8Zl*g0Sl$G7=R*Vbw@2RJc{eMfhGdn8=>)+OR2Aa@ z1}U%ze?in~0&v8Kn<~0Q z=lFCqF)gNVTKlc_Cf5zRmM>8}oo2tNIXshC2kO;q>yo+J-4S14ux=(xe(W>sWwor|8d46k(xb@Yp-oEnbx85xY3n72Hx9CnTDWQs|Ob)Wv zGpgQu&8tVbrxS(YNG?VdAvEd5ZJ+={XP>Ij+7-1c$!@E;|Kpp!lTX+g&pQt9*_9}s z?&RaIcYF%;W-N$(L)XCy2D~elnJu&n;XdzUlvL8&*QX7@yv^P!#Bz0R3A#Rx^-SN>@R>WKFyQD1#ecvWlCxGY>_lN847oq>p7hT8#>ZOj{qm#!YY z!=e4PiW)PVNoN2oB#trObUmvPhh|WOVQ-m7P|N(NK4HykNT1WBGs=Q?KWghZN(?~_ z+#{Elkf&}Rd$Hvs>x1UUOfkMAuM_HT^k@Sl}r1IHR_3gFsp+Gok0sb#u-BfipoaZ?-5N#5X!G1^2X<%jpB1 zy?|#`A+G$^R$(1r*kmHC`;Dl9BPbcD<+onF>yTOQdUy5dJ<|omuTzJ;f~N#JkI-;Y z3iFK|-Ykm(*Vh<;tu;Z^SM&7`4L+{Yk(I7bDGFD%ndd8-Hz`Mkiu(+a98>JIn@iDo zrd$c>9(4Ne1}-|Yo?M?_*Xb4_e2jj6^$ll{RIibTrnx&Sl-lxkBfAJtsCzulS%*pi zX4CBoRQXqVzSA^As!gQ|G*y!wGkpVedQkx*UYz5|VEwkx3}*-k2O?%d9JsD;9IAg@ zofmyv9ZegIgFI=ArL|s+kTHMmcjcS6yPC!HAnA~?mGWVLQ0p(l&$2ZMalYJt$2bQI zL!@-Y(_J$spH^u{zF6`2pz>~lcxMSCkv0U~BslS<=1_mX@qbhQrPK{dZ-hehVHr5FLz!Tk&D;tTS`pBE zi06VesJ}YIa}roQdT-j@K|V3Hf!Z$gJ5M5u$ULMFH-)3M35i3(M@LnDVu)N^fEI3Z zFrnPgla5sNdE(GI1;`R)mm{5htzKD-tVP~2mn6uN4b$5>38CISI0|+m`3WLy+o;b| za>yP;qnxNhIgw?b_U1E*IimAA=oZ~iz7~?H zVPRXpP?$MtHBqhnZ6TW<>N=VwR~;jyf)R7!rf7d%@B8qy#Dz;p3)rzRVHZ&?N&Ar> z!qVhHKACy^v^D-AZe?S8@IU*#41#+5G9&r&5|bU=kE%#j`FVJ`(Qg4 zo`>nE?Z+;~XxXM1e%{XK_q#H$fhjZNv82!jZq0`S?%(E0Yf~=aMakdY8O_Hw-28P= zFzJ)!G{->Px;9NSt5+PflVaVyj70Md2a>f>k_sm2gl_+Q# zOZNAl{i?qBWgwErmaN9NO?C7tsqvgOIMpL{o)5N1UrKwAli#K3%Rt!uA3vn!Y8a5v zy$0QCK0s%7?z4>$z->FwarSEXLP7aj!>q=XB8=2pWGM8(g}56r@>pKvi9Rl1P*Ej7 zqX0mjH)5fqM=$0rYg$&Cqq?(;7~_Bf_>mJdoXSaMvG6cPuk~Y4X|gr_@R!~BmeuUt zi>^9Ep6S(XvZ_t%&N?s<#PY?C{Xk1$?0RUg;py7SA!J8W)eZYSJyyS|S3Kf`$)3_yecAOS54jlrigfYXpjnZ#kl2s`zgmaSGA-Kn>K%cGnfpP#=g zfeVz|#EFk6K6xwQ|631;un)`qdS>5BTCq+IKOVh})A*XoUUYJX468XC1+ho`e=i!5 zSiSgBsv~jLixSuT4=xjP`bYhQPDfW~&4e*lUY!HAOEO{h4FZ&mm)|7H$rCjht*BF% z;Y}9tEQMcK?%wxfR{4hTeHih3f4DKyarZ4xa1Cul``V{YN$<$St&2G^ z?yp;$WXytHemrN1__$X-7a@Bv?N3o$jr2S9jV#g+{WMbk^sq@Cfa8i#c3K%espx80 z6O|Ti1f8uWk>P$iWr$@;wMT{tSh_USoiksqIYk~N5pDC}Vb50|jplylwVto{9o+s6 zp@sVA6^QyOKE(g-3#^GZ`i}U~``+FW0;Ud6h3G>3x>%!@vJgbIK5O2Z8N+Y>BPWr4 z9e>aA;f-_OEYmhx7atM-FaSxA9ShEC)4vG!yT^<%QyfJUf)z3N-X2~k#QxC~fcRB^ z!mjH)?iK^0b1h-#=@)qw2h|WxqV|4J|1R_WLG1a1SsYbZ^LI8Jn|s zs!mc886pH&+OxEi_NxC`O(wcG6*RR880h~uL%P@xsFQcDJPG?}-vPAM^={VOu#5gb D^5d?$ literal 0 HcmV?d00001 diff --git a/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/Filters.kt b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/Filters.kt new file mode 100644 index 000000000..80d0e4075 --- /dev/null +++ b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/Filters.kt @@ -0,0 +1,112 @@ +package eu.kanade.tachiyomi.extension.all.kavita + +import eu.kanade.tachiyomi.extension.all.kavita.KavitaConstants.noSmartFilterSelected +import eu.kanade.tachiyomi.source.model.Filter + +class UserRating : + Filter.Select( + "Minimum Rating", + arrayOf( + "Any", + "1 star", + "2 stars", + "3 stars", + "4 stars", + "5 stars", + ), + ) +class SmartFiltersFilter(smartFilters: Array) : + Filter.Select("Smart Filters", arrayOf(noSmartFilterSelected) + smartFilters) +class SortFilter(sortables: Array) : Filter.Sort("Sort by", sortables, Selection(0, true)) + +val sortableList = listOf( + Pair("Sort name", 1), + Pair("Created", 2), + Pair("Last modified", 3), + Pair("Item added", 4), + Pair("Time to Read", 5), + Pair("Release year", 6), +) + +class StatusFilter(name: String) : Filter.CheckBox(name, false) +class StatusFilterGroup(filters: List) : + Filter.Group("Status", filters) + +class ReleaseYearRange(name: String) : Filter.Text(name) +class ReleaseYearRangeGroup(filters: List) : + Filter.Group("Release Year", filters) +class GenreFilter(name: String) : Filter.TriState(name) +class GenreFilterGroup(genres: List) : + Filter.Group("Genres", genres) + +class TagFilter(name: String) : Filter.TriState(name) +class TagFilterGroup(tags: List) : Filter.Group("Tags", tags) + +class AgeRatingFilter(name: String) : Filter.TriState(name) +class AgeRatingFilterGroup(ageRatings: List) : + Filter.Group("Age Rating", ageRatings) + +class FormatFilter(name: String) : Filter.CheckBox(name, false) +class FormatsFilterGroup(formats: List) : + Filter.Group("Formats", formats) + +class CollectionFilter(name: String) : Filter.TriState(name) +class CollectionFilterGroup(collections: List) : + Filter.Group("Collection", collections) + +class LanguageFilter(name: String) : Filter.TriState(name) +class LanguageFilterGroup(languages: List) : + Filter.Group("Language", languages) + +class LibraryFilter(library: String) : Filter.TriState(library) +class LibrariesFilterGroup(libraries: List) : + Filter.Group("Libraries", libraries) + +class PubStatusFilter(name: String) : Filter.CheckBox(name, false) +class PubStatusFilterGroup(status: List) : + Filter.Group("Publication Status", status) + +class PeopleHeaderFilter(name: String) : + Filter.Header(name) +class PeopleSeparatorFilter : + Filter.Separator() + +class WriterPeopleFilter(name: String) : Filter.CheckBox(name, false) +class WriterPeopleFilterGroup(peoples: List) : + Filter.Group("Writer", peoples) + +class PencillerPeopleFilter(name: String) : Filter.CheckBox(name, false) +class PencillerPeopleFilterGroup(peoples: List) : + Filter.Group("Penciller", peoples) + +class InkerPeopleFilter(name: String) : Filter.CheckBox(name, false) +class InkerPeopleFilterGroup(peoples: List) : + Filter.Group("Inker", peoples) + +class ColoristPeopleFilter(name: String) : Filter.CheckBox(name, false) +class ColoristPeopleFilterGroup(peoples: List) : + Filter.Group("Colorist", peoples) + +class LettererPeopleFilter(name: String) : Filter.CheckBox(name, false) +class LettererPeopleFilterGroup(peoples: List) : + Filter.Group("Letterer", peoples) + +class CoverArtistPeopleFilter(name: String) : Filter.CheckBox(name, false) +class CoverArtistPeopleFilterGroup(peoples: List) : + Filter.Group("Cover Artist", peoples) + +class EditorPeopleFilter(name: String) : Filter.CheckBox(name, false) +class EditorPeopleFilterGroup(peoples: List) : + Filter.Group("Editor", peoples) + +class PublisherPeopleFilter(name: String) : Filter.CheckBox(name, false) +class PublisherPeopleFilterGroup(peoples: List) : + Filter.Group("Publisher", peoples) + +class CharacterPeopleFilter(name: String) : Filter.CheckBox(name, false) +class CharacterPeopleFilterGroup(peoples: List) : + Filter.Group("Character", peoples) + +class TranslatorPeopleFilter(name: String) : Filter.CheckBox(name, false) +class TranslatorPeopleFilterGroup(peoples: List) : + Filter.Group("Translator", peoples) diff --git a/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/Kavita.kt b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/Kavita.kt new file mode 100644 index 000000000..079e457a9 --- /dev/null +++ b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/Kavita.kt @@ -0,0 +1,1264 @@ +package eu.kanade.tachiyomi.extension.all.kavita + +import android.app.Application +import android.content.SharedPreferences +import android.text.InputType +import android.util.Log +import android.widget.Toast +import androidx.preference.EditTextPreference +import androidx.preference.MultiSelectListPreference +import eu.kanade.tachiyomi.AppInfo +import eu.kanade.tachiyomi.extension.all.kavita.dto.AuthenticationDto +import eu.kanade.tachiyomi.extension.all.kavita.dto.FilterComparison +import eu.kanade.tachiyomi.extension.all.kavita.dto.FilterField +import eu.kanade.tachiyomi.extension.all.kavita.dto.FilterStatementDto +import eu.kanade.tachiyomi.extension.all.kavita.dto.FilterV2Dto +import eu.kanade.tachiyomi.extension.all.kavita.dto.MangaFormat +import eu.kanade.tachiyomi.extension.all.kavita.dto.MetadataAgeRatings +import eu.kanade.tachiyomi.extension.all.kavita.dto.MetadataCollections +import eu.kanade.tachiyomi.extension.all.kavita.dto.MetadataGenres +import eu.kanade.tachiyomi.extension.all.kavita.dto.MetadataLanguages +import eu.kanade.tachiyomi.extension.all.kavita.dto.MetadataLibrary +import eu.kanade.tachiyomi.extension.all.kavita.dto.MetadataPayload +import eu.kanade.tachiyomi.extension.all.kavita.dto.MetadataPeople +import eu.kanade.tachiyomi.extension.all.kavita.dto.MetadataPubStatus +import eu.kanade.tachiyomi.extension.all.kavita.dto.MetadataTag +import eu.kanade.tachiyomi.extension.all.kavita.dto.PersonRole +import eu.kanade.tachiyomi.extension.all.kavita.dto.SeriesDto +import eu.kanade.tachiyomi.extension.all.kavita.dto.SeriesMetadataDto +import eu.kanade.tachiyomi.extension.all.kavita.dto.ServerInfoDto +import eu.kanade.tachiyomi.extension.all.kavita.dto.SmartFilter +import eu.kanade.tachiyomi.extension.all.kavita.dto.SortFieldEnum +import eu.kanade.tachiyomi.extension.all.kavita.dto.SortOptions +import eu.kanade.tachiyomi.extension.all.kavita.dto.VolumeDto +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.POST +import eu.kanade.tachiyomi.network.asObservableSuccess +import eu.kanade.tachiyomi.source.ConfigurableSource +import eu.kanade.tachiyomi.source.UnmeteredSource +import eu.kanade.tachiyomi.source.model.Filter +import eu.kanade.tachiyomi.source.model.Filter.TriState.Companion.STATE_EXCLUDE +import eu.kanade.tachiyomi.source.model.Filter.TriState.Companion.STATE_INCLUDE +import eu.kanade.tachiyomi.source.model.FilterList +import eu.kanade.tachiyomi.source.model.MangasPage +import eu.kanade.tachiyomi.source.model.Page +import eu.kanade.tachiyomi.source.model.SChapter +import eu.kanade.tachiyomi.source.model.SManga +import eu.kanade.tachiyomi.source.online.HttpSource +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.put +import okhttp3.Dns +import okhttp3.Headers +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import rx.Observable +import rx.Single +import rx.schedulers.Schedulers +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import uy.kohesive.injekt.injectLazy +import java.io.IOException +import java.net.ConnectException +import java.security.MessageDigest +import java.util.Locale + +class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSource, HttpSource() { + private val helper = KavitaHelper() + override val client: OkHttpClient = + network.client.newBuilder() + .dns(Dns.SYSTEM) + .build() + override val id by lazy { + val key = "${"kavita_$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 + } + private val preferences: SharedPreferences by lazy { + Injekt.get().getSharedPreferences("source_$id", 0x0000) + } + override val name = "${KavitaInt.KAVITA_NAME} (${preferences.getString(KavitaConstants.customSourceNamePref, suffix)})" + override val lang = "all" + override val supportsLatest = true + private val apiUrl by lazy { getPrefApiUrl() } + override val baseUrl by lazy { getPrefBaseUrl() } + private val address by lazy { getPrefAddress() } // Address for the Kavita OPDS url. Should be http(s)://host:(port)/api/opds/api-key + private val apiKey by lazy { getPrefApiKey() } + private var jwtToken = "" // * JWT Token for authentication with the server. Stored in memory. + private val LOG_TAG = """Kavita_${"[$suffix]_" + preferences.getString(KavitaConstants.customSourceNamePref, "[$suffix]")!!.replace(' ', '_')}""" + private var isLogged = false // Used to know if login was correct and not send login requests anymore + private val json: Json by injectLazy() + + private var series = emptyList() // Acts as a cache + + private inline fun Response.parseAs(): T = + use { + if (it.code == 401) { + Log.e(LOG_TAG, "Http error 401 - Not authorized: ${it.request.url}") + Throwable("Http error 401 - Not authorized: ${it.request.url}") + } + + if (it.peekBody(Long.MAX_VALUE).string().isEmpty()) { + Log.e(LOG_TAG, "Empty body String for request url: ${it.request.url}") + throw EmptyRequestBody( + "Body of the response is empty. RequestUrl=${it.request.url}\nPlease check your kavita instance is up to date", + Throwable("Error. Request body is empty"), + ) + } + json.decodeFromString(it.body.string()) + } + + /** + * Custom implementation for fetch popular, latest and search + * Handles and logs errors to provide a more detailed exception to the users. + */ + private fun fetch(request: Request): Observable { + return client.newCall(request) + .asObservableSuccess() + .onErrorResumeNext { throwable -> + // Get Http code + val field = throwable.javaClass.getDeclaredField("code") + field.isAccessible = true // Make the field accessible + try { + var code = field.get(throwable) // Get the value of the code property + Log.e(LOG_TAG, "Error fetching manga: ${throwable.message}", throwable) + if (code as Int !in intArrayOf(401, 201, 500)) { + code = 500 + } + return@onErrorResumeNext Observable.error(IOException("Http Error: $code\n ${helper.intl["http_errors_$code"]}\n${helper.intl["check_version"]}")) + } catch (e: Exception) { + Log.e(LOG_TAG, e.toString(), e) + return@onErrorResumeNext Observable.error(e) + } + } + .map { response -> + popularMangaParse(response) + } + } + + override fun fetchPopularManga(page: Int) = + fetch(popularMangaRequest(page)) + + override fun fetchLatestUpdates(page: Int) = + fetch(latestUpdatesRequest(page)) + + override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable = + fetch(searchMangaRequest(page, query, filters)) + + override fun popularMangaParse(response: Response): MangasPage { + try { + val result = response.parseAs>() + series = result + val mangaList = result.map { item -> helper.createSeriesDto(item, apiUrl, apiKey) } + return MangasPage(mangaList, helper.hasNextPage(response)) + } catch (e: Exception) { + Log.e(LOG_TAG, "Unhandled exception", e) + throw IOException(helper.intl["check_version"]) + } + } + + override fun popularMangaRequest(page: Int): Request { + if (!isLogged) { + doLogin() + } + val payload = buildFilterBody(currentFilter) + return POST( + "$apiUrl/series/all-v2?pageNumber=$page&pageSize=20", + headersBuilder().build(), + payload.toRequestBody(JSON_MEDIA_TYPE), + ) + } + + override fun latestUpdatesRequest(page: Int): Request { + if (!isLogged) { + doLogin() + } + // Hardcode exclude epubs + val filter = FilterV2Dto(sortOptions = SortOptions(SortFieldEnum.LastChapterAdded.type, false)) + filter.statements.add(FilterStatementDto(FilterComparison.NotContains.type, FilterField.Formats.type, "3")) + val payload = json.encodeToJsonElement(filter).toString() + return POST( + "$apiUrl/series/all-v2?pageNumber=$page&pageSize=20", + headersBuilder().build(), + payload.toRequestBody(JSON_MEDIA_TYPE), + ) + } + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val newFilter = MetadataPayload() // need to reset it or will double + val smartFilterFilter = filters.find { it is SmartFiltersFilter } + // If a SmartFilter selected, apply its filter and return that + if (smartFilterFilter?.state != 0 && smartFilterFilter != null) { + val index = try { + smartFilterFilter?.state as Int - 1 + } catch (e: Exception) { + Log.e(LOG_TAG, e.toString(), e) + 0 + } + + val filter: SmartFilter = smartFilters[index] + val payload = buildJsonObject { + put("EncodedFilter", filter.filter) + } + // Decode selected filters + val request = POST( + "$apiUrl/filter/decode", + headersBuilder().build(), + payload.toString().toRequestBody(JSON_MEDIA_TYPE), + ) + client.newCall(request).execute().use { + if (it.code == 200) { + // Hardcode exclude epub + val decoded_filter = json.decodeFromString(it.body.string()) + decoded_filter.statements.add(FilterStatementDto(FilterComparison.NotContains.type, FilterField.Formats.type, "3")) + + // Make request with selected filters + return POST( + "$apiUrl/series/all-v2?pageNumber=$page&pageSize=20", + headersBuilder().build(), + json.encodeToJsonElement(decoded_filter).toString().toRequestBody(JSON_MEDIA_TYPE), + ) + } else { + Log.e(LOG_TAG, "Failed to decode SmartFilter: ${it.code}\n" + it.message) + throw IOException(helper.intl["version_exceptions_smart_filter"]) + } + } + } + // Else apply user filters + + filters.forEach { filter -> + when (filter) { + is SortFilter -> { + if (filter.state != null) { + newFilter.sorting = filter.state!!.index + 1 + newFilter.sorting_asc = filter.state!!.ascending + } + } + + is StatusFilterGroup -> { + filter.state.forEach { content -> + if (content.state) { + newFilter.readStatus.add(content.name) + } + } + } + + is ReleaseYearRangeGroup -> { + filter.state.forEach { content -> + if (content.state.isNotEmpty()) { + if (content.name == "Min") { + newFilter.releaseYearRangeMin = content.state.toInt() + } + if (content.name == "Max") { + newFilter.releaseYearRangeMax = content.state.toInt() + } + } + } + } + + is GenreFilterGroup -> { + filter.state.forEach { content -> + if (content.state == STATE_INCLUDE) { + newFilter.genres_i.add(genresListMeta.find { it.title == content.name }!!.id) + } else if (content.state == STATE_EXCLUDE) { + newFilter.genres_e.add(genresListMeta.find { it.title == content.name }!!.id) + } + } + } + + is UserRating -> { + newFilter.userRating = filter.state + } + + is TagFilterGroup -> { + filter.state.forEach { content -> + if (content.state == STATE_INCLUDE) { + newFilter.tags_i.add(tagsListMeta.find { it.title == content.name }!!.id) + } else if (content.state == STATE_EXCLUDE) { + newFilter.tags_e.add(tagsListMeta.find { it.title == content.name }!!.id) + } + } + } + + is AgeRatingFilterGroup -> { + filter.state.forEach { content -> + if (content.state == STATE_INCLUDE) { + newFilter.ageRating_i.add(ageRatingsListMeta.find { it.title == content.name }!!.value) + } else if (content.state == STATE_EXCLUDE) { + newFilter.ageRating_e.add(ageRatingsListMeta.find { it.title == content.name }!!.value) + } + } + } + + is FormatsFilterGroup -> { + filter.state.forEach { content -> + if (content.state) { + newFilter.formats.add(MangaFormat.valueOf(content.name).ordinal) + } + } + } + + is CollectionFilterGroup -> { + filter.state.forEach { content -> + if (content.state == STATE_INCLUDE) { + newFilter.collections_i.add(collectionsListMeta.find { it.title == content.name }!!.id) + } else if (content.state == STATE_EXCLUDE) { + newFilter.collections_e.add(collectionsListMeta.find { it.title == content.name }!!.id) + } + } + } + + is LanguageFilterGroup -> { + filter.state.forEach { content -> + if (content.state == STATE_INCLUDE) { + newFilter.language_i.add(languagesListMeta.find { it.title == content.name }!!.isoCode) + } else if (content.state == STATE_EXCLUDE) { + newFilter.language_e.add(languagesListMeta.find { it.title == content.name }!!.isoCode) + } + } + } + + is LibrariesFilterGroup -> { + filter.state.forEach { content -> + if (content.state == STATE_INCLUDE) { + newFilter.libraries_i.add(libraryListMeta.find { it.name == content.name }!!.id) + } else if (content.state == STATE_EXCLUDE) { + newFilter.libraries_e.add(libraryListMeta.find { it.name == content.name }!!.id) + } + } + } + + is PubStatusFilterGroup -> { + filter.state.forEach { content -> + if (content.state) { + newFilter.pubStatus.add(pubStatusListMeta.find { it.title == content.name }!!.value) + } + } + } + + is WriterPeopleFilterGroup -> { + filter.state.forEach { content -> + if (content.state) { + newFilter.peopleWriters.add(peopleListMeta.find { it.name == content.name }!!.id) + } + } + } + + is PencillerPeopleFilterGroup -> { + filter.state.forEach { content -> + if (content.state) { + newFilter.peoplePenciller.add(peopleListMeta.find { it.name == content.name }!!.id) + } + } + } + + is InkerPeopleFilterGroup -> { + filter.state.forEach { content -> + if (content.state) { + newFilter.peopleInker.add(peopleListMeta.find { it.name == content.name }!!.id) + } + } + } + + is ColoristPeopleFilterGroup -> { + filter.state.forEach { content -> + if (content.state) { + newFilter.peoplePeoplecolorist.add(peopleListMeta.find { it.name == content.name }!!.id) + } + } + } + + is LettererPeopleFilterGroup -> { + filter.state.forEach { content -> + if (content.state) { + newFilter.peopleLetterer.add(peopleListMeta.find { it.name == content.name }!!.id) + } + } + } + + is CoverArtistPeopleFilterGroup -> { + filter.state.forEach { content -> + if (content.state) { + newFilter.peopleCoverArtist.add(peopleListMeta.find { it.name == content.name }!!.id) + } + } + } + + is EditorPeopleFilterGroup -> { + filter.state.forEach { content -> + if (content.state) { + newFilter.peopleEditor.add(peopleListMeta.find { it.name == content.name }!!.id) + } + } + } + + is PublisherPeopleFilterGroup -> { + filter.state.forEach { content -> + if (content.state) { + newFilter.peoplePublisher.add(peopleListMeta.find { it.name == content.name }!!.id) + } + } + } + + is CharacterPeopleFilterGroup -> { + filter.state.forEach { content -> + if (content.state) { + newFilter.peopleCharacter.add(peopleListMeta.find { it.name == content.name }!!.id) + } + } + } + + is TranslatorPeopleFilterGroup -> { + filter.state.forEach { content -> + if (content.state) { + newFilter.peopleTranslator.add(peopleListMeta.find { it.name == content.name }!!.id) + } + } + } + + else -> {} + } + } + newFilter.seriesNameQuery = query + currentFilter = newFilter + return popularMangaRequest(page) + } + + /* + * MANGA DETAILS (metadata about series) + * **/ + + override fun fetchMangaDetails(manga: SManga): Observable { + val serieId = helper.getIdFromUrl(manga.url) + return client.newCall(GET("$apiUrl/series/metadata?seriesId=$serieId", headersBuilder().build())) + .asObservableSuccess() + .map { response -> + mangaDetailsParse(response).apply { initialized = true } + } + } + + override fun mangaDetailsRequest(manga: SManga): Request { + val serieId = helper.getIdFromUrl(manga.url) + val foundSerie = series.find { dto -> dto.id == serieId } + return GET( + "$baseUrl/library/${foundSerie!!.libraryId}/series/$serieId", + headersBuilder().build(), + ) + } + + override fun mangaDetailsParse(response: Response): SManga { + val result = response.parseAs() + + val existingSeries = series.find { dto -> dto.id == result.seriesId } + if (existingSeries != null) { + val manga = helper.createSeriesDto(existingSeries, apiUrl, apiKey) + manga.url = "$apiUrl/Series/${result.seriesId}" + manga.artist = result.coverArtists.joinToString { it.name } + manga.description = result.summary + manga.author = result.writers.joinToString { it.name } + manga.genre = result.genres.joinToString { it.title } + manga.thumbnail_url = "$apiUrl/image/series-cover?seriesId=${result.seriesId}&apiKey=$apiKey" + + return manga + } + val serieDto = client.newCall(GET("$apiUrl/Series/${result.seriesId}", headersBuilder().build())) + .execute() + .parseAs() + + return SManga.create().apply { + url = "$apiUrl/Series/${result.seriesId}" + artist = result.coverArtists.joinToString { it.name } + description = result.summary + author = result.writers.joinToString { it.name } + genre = result.genres.joinToString { it.title } + title = serieDto.name + thumbnail_url = "$apiUrl/image/series-cover?seriesId=${result.seriesId}&apiKey=$apiKey" + status = when (result.publicationStatus) { + 4 -> SManga.PUBLISHING_FINISHED + 2 -> SManga.COMPLETED + 0 -> SManga.ONGOING + 3 -> SManga.CANCELLED + 1 -> SManga.ON_HIATUS + else -> SManga.UNKNOWN + } + } + } + + /* + * CHAPTER LIST + * **/ + override fun chapterListRequest(manga: SManga): Request { + val url = "$apiUrl/Series/volumes?seriesId=${helper.getIdFromUrl(manga.url)}" + return GET(url, headersBuilder().build()) + } + + override fun chapterListParse(response: Response): List { + try { + val volumes = response.parseAs>() + val allChapterList = mutableListOf() + volumes.forEach { volume -> + run { + if (volume.number == 0) { + // Regular chapters + volume.chapters.map { + allChapterList.add(helper.chapterFromObject(it)) + } + } else { + // Volume chapter + volume.chapters.map { + allChapterList.add(helper.chapterFromVolume(it, volume)) + } + } + } + } + + allChapterList.sortWith(KavitaHelper.CompareChapters) + return allChapterList + } catch (e: Exception) { + Log.e(LOG_TAG, "Unhandled exception parsing chapters. Send logs to kavita devs", e) + throw IOException(helper.intl["version_exceptions_chapters_parse"]) + } + } + + /** + * Fetches the "url" of each page from the chapter + * **/ + override fun pageListRequest(chapter: SChapter): Request { + return GET("$apiUrl/${chapter.url}", headersBuilder().build()) + } + + override fun fetchPageList(chapter: SChapter): Observable> { + val chapterId = chapter.url + val numPages = chapter.scanlator?.replace(" pages", "")?.toInt() + val numPages2 = "$numPages".toInt() - 1 + val pages = mutableListOf() + for (i in 0..numPages2) { + pages.add( + Page( + index = i, + imageUrl = "$apiUrl/Reader/image?chapterId=$chapterId&page=$i&extractPdf=true&apiKey=$apiKey", + ), + ) + } + return Observable.just(pages) + } + + override fun latestUpdatesParse(response: Response): MangasPage = + throw UnsupportedOperationException("Not used") + + override fun pageListParse(response: Response): List = + throw UnsupportedOperationException("Not used") + + override fun searchMangaParse(response: Response): MangasPage = + throw UnsupportedOperationException("Not used") + + override fun imageUrlParse(response: Response): String = "" + + /* + * FILTERING + **/ + + private var currentFilter: MetadataPayload = MetadataPayload() + + /** Some variable names already exist. im not good at naming add Meta suffix */ + private var genresListMeta = emptyList() + private var tagsListMeta = emptyList() + private var ageRatingsListMeta = emptyList() + private var peopleListMeta = emptyList() + private var pubStatusListMeta = emptyList() + private var languagesListMeta = emptyList() + private var libraryListMeta = emptyList() + private var collectionsListMeta = emptyList() + private var smartFilters = emptyList() + private val personRoles = listOf( + "Writer", + "Penciller", + "Inker", + "Colorist", + "Letterer", + "CoverArtist", + "Editor", + "Publisher", + "Character", + "Translator", + ) + + /** + * Loads the enabled filters if they are not empty so tachiyomi can show them to the user + */ + override fun getFilterList(): FilterList { + val toggledFilters = getToggledFilters() + + val filters = try { + val peopleInRoles = mutableListOf>() + personRoles.map { role -> + val peoplesWithRole = mutableListOf() + peopleListMeta.map { + if (it.role == helper.safeValueOf(role).role) { + peoplesWithRole.add(it) + } + } + peopleInRoles.add(peoplesWithRole) + } + + val filtersLoaded = mutableListOf>() + + if (sortableList.isNotEmpty() and toggledFilters.contains("Sort Options")) { + filtersLoaded.add( + SortFilter(sortableList.map { it.first }.toTypedArray()), + ) + if (smartFilters.isNotEmpty()) { + filtersLoaded.add( + SmartFiltersFilter(smartFilters.map { it.name }.toTypedArray()), + + ) + } + } + if (toggledFilters.contains("Read Status")) { + filtersLoaded.add( + StatusFilterGroup( + listOf( + "notRead", + "inProgress", + "read", + ).map { StatusFilter(it) }, + ), + ) + } + if (toggledFilters.contains("ReleaseYearRange")) { + filtersLoaded.add( + ReleaseYearRangeGroup( + listOf("Min", "Max").map { ReleaseYearRange(it) }, + ), + ) + } + + if (genresListMeta.isNotEmpty() and toggledFilters.contains("Genres")) { + filtersLoaded.add( + GenreFilterGroup(genresListMeta.map { GenreFilter(it.title) }), + ) + } + if (tagsListMeta.isNotEmpty() and toggledFilters.contains("Tags")) { + filtersLoaded.add( + TagFilterGroup(tagsListMeta.map { TagFilter(it.title) }), + ) + } + if (ageRatingsListMeta.isNotEmpty() and toggledFilters.contains("Age Rating")) { + filtersLoaded.add( + AgeRatingFilterGroup(ageRatingsListMeta.map { AgeRatingFilter(it.title) }), + ) + } + if (toggledFilters.contains("Format")) { + filtersLoaded.add( + FormatsFilterGroup( + listOf( + "Image", + "Archive", + "Pdf", + "Unknown", + ).map { FormatFilter(it) }, + ), + ) + } + if (collectionsListMeta.isNotEmpty() and toggledFilters.contains("Collections")) { + filtersLoaded.add( + CollectionFilterGroup(collectionsListMeta.map { CollectionFilter(it.title) }), + ) + } + if (languagesListMeta.isNotEmpty() and toggledFilters.contains("Languages")) { + filtersLoaded.add( + LanguageFilterGroup(languagesListMeta.map { LanguageFilter(it.title) }), + ) + } + if (libraryListMeta.isNotEmpty() and toggledFilters.contains("Libraries")) { + filtersLoaded.add( + LibrariesFilterGroup(libraryListMeta.map { LibraryFilter(it.name) }), + ) + } + if (pubStatusListMeta.isNotEmpty() and toggledFilters.contains("Publication Status")) { + filtersLoaded.add( + PubStatusFilterGroup(pubStatusListMeta.map { PubStatusFilter(it.title) }), + ) + } + if (pubStatusListMeta.isNotEmpty() and toggledFilters.contains("Rating")) { + filtersLoaded.add( + UserRating(), + ) + } + + // People Metadata: + if (personRoles.isNotEmpty() and toggledFilters.any { personRoles.contains(it) }) { + filtersLoaded.addAll( + listOf>( + PeopleHeaderFilter(""), + PeopleSeparatorFilter(), + PeopleHeaderFilter("PEOPLE"), + ), + ) + if (peopleInRoles[0].isNotEmpty() and toggledFilters.contains("Writer")) { + filtersLoaded.add( + WriterPeopleFilterGroup( + peopleInRoles[0].map { WriterPeopleFilter(it.name) }, + ), + ) + } + if (peopleInRoles[1].isNotEmpty() and toggledFilters.contains("Penciller")) { + filtersLoaded.add( + PencillerPeopleFilterGroup( + peopleInRoles[1].map { PencillerPeopleFilter(it.name) }, + ), + ) + } + if (peopleInRoles[2].isNotEmpty() and toggledFilters.contains("Inker")) { + filtersLoaded.add( + InkerPeopleFilterGroup( + peopleInRoles[2].map { InkerPeopleFilter(it.name) }, + ), + ) + } + if (peopleInRoles[3].isNotEmpty() and toggledFilters.contains("Colorist")) { + filtersLoaded.add( + ColoristPeopleFilterGroup( + peopleInRoles[3].map { ColoristPeopleFilter(it.name) }, + ), + ) + } + if (peopleInRoles[4].isNotEmpty() and toggledFilters.contains("Letterer")) { + filtersLoaded.add( + LettererPeopleFilterGroup( + peopleInRoles[4].map { LettererPeopleFilter(it.name) }, + ), + ) + } + if (peopleInRoles[5].isNotEmpty() and toggledFilters.contains("CoverArtist")) { + filtersLoaded.add( + CoverArtistPeopleFilterGroup( + peopleInRoles[5].map { CoverArtistPeopleFilter(it.name) }, + ), + ) + } + if (peopleInRoles[6].isNotEmpty() and toggledFilters.contains("Editor")) { + filtersLoaded.add( + EditorPeopleFilterGroup( + peopleInRoles[6].map { EditorPeopleFilter(it.name) }, + ), + ) + } + + if (peopleInRoles[7].isNotEmpty() and toggledFilters.contains("Publisher")) { + filtersLoaded.add( + PublisherPeopleFilterGroup( + peopleInRoles[7].map { PublisherPeopleFilter(it.name) }, + ), + ) + } + if (peopleInRoles[8].isNotEmpty() and toggledFilters.contains("Character")) { + filtersLoaded.add( + CharacterPeopleFilterGroup( + peopleInRoles[8].map { CharacterPeopleFilter(it.name) }, + ), + ) + } + if (peopleInRoles[9].isNotEmpty() and toggledFilters.contains("Translator")) { + filtersLoaded.add( + TranslatorPeopleFilterGroup( + peopleInRoles[9].map { TranslatorPeopleFilter(it.name) }, + ), + ) + filtersLoaded + } else { + filtersLoaded + } + } else { + filtersLoaded + } + } catch (e: Exception) { + Log.e(LOG_TAG, "[FILTERS] Error while creating filter list", e) + emptyList() + } + return FilterList(filters) + } + + /** + * Returns a FilterV2Dto encoded as a json string with values taken from filter + */ + private fun buildFilterBody(filter: MetadataPayload): String { + val filter_dto = FilterV2Dto() + filter_dto.sortOptions.sortField = filter.sorting + filter_dto.sortOptions.isAscending = filter.sorting_asc + + // Fields that support contains and not contains statements + val containsAndNotTriplets = listOf( + Triple(FilterField.Libraries, filter.libraries_i, filter.libraries_e), + Triple(FilterField.Tags, filter.tags_i, filter.tags_e), + Triple(FilterField.Languages, filter.language_i, filter.genres_e), + Triple(FilterField.AgeRating, filter.ageRating_i, filter.ageRating_e), + Triple(FilterField.Genres, filter.genres_i, filter.genres_e), + Triple(FilterField.CollectionTags, filter.collections_i, filter.collections_e), + ) + filter_dto.addContainsNotTriple(containsAndNotTriplets) + // Fields that have must contains statements + val peoplePairs = listOf( + + Pair(FilterField.Writers, filter.peopleWriters), + Pair(FilterField.Penciller, filter.peoplePenciller), + Pair(FilterField.Inker, filter.peopleInker), + Pair(FilterField.Colorist, filter.peopleCharacter), + Pair(FilterField.Letterer, filter.peopleLetterer), + Pair(FilterField.CoverArtist, filter.peopleCoverArtist), + Pair(FilterField.Editor, filter.peopleEditor), + Pair(FilterField.Publisher, filter.peoplePublisher), + Pair(FilterField.Characters, filter.peopleCharacter), + Pair(FilterField.Translators, filter.peopleTranslator), + + Pair(FilterField.PublicationStatus, filter.pubStatus), + ) + filter_dto.addPeople(peoplePairs) + + // Customized statements + filter_dto.addStatement(FilterComparison.Contains, FilterField.Formats, filter.formats) + filter_dto.addStatement(FilterComparison.Matches, FilterField.SeriesName, filter.seriesNameQuery) + // Hardcoded statement to filter out epubs: + filter_dto.addStatement(FilterComparison.NotContains, FilterField.Formats, "3") + if (filter.readStatus.isNotEmpty()) { + filter.readStatus.forEach { + if (it == "notRead") { + filter_dto.addStatement(FilterComparison.Equal, FilterField.ReadProgress, "0") + } else if (it == "inProgress") { + filter_dto.addStatement(FilterComparison.GreaterThan, FilterField.ReadProgress, "0") + filter_dto.addStatement(FilterComparison.LessThan, FilterField.ReadProgress, "100") + } else if (it == "read") { + filter_dto.addStatement(FilterComparison.Equal, FilterField.ReadProgress, "100") + } + } + } + // todo: check statement + // filter_dto.addStatement(FilterComparison.GreaterThanEqual, FilterField.UserRating, filter.userRating.toString()) + if (filter.releaseYearRangeMin != 0) { + filter_dto.addStatement(FilterComparison.GreaterThan, FilterField.ReleaseYear, filter.releaseYearRangeMin.toString()) + } + + if (filter.releaseYearRangeMax != 0) { + filter_dto.addStatement(FilterComparison.LessThan, FilterField.ReleaseYear, filter.releaseYearRangeMax.toString()) + } + return json.encodeToJsonElement(filter_dto).toString() + } + + class LoginErrorException(message: String? = null, cause: Throwable? = null) : Exception(message, cause) { + constructor(cause: Throwable) : this(null, cause) + } + + class OpdsurlExistsInPref(message: String? = null, cause: Throwable? = null) : Exception(message, cause) { + constructor(cause: Throwable) : this(null, cause) + } + + class EmptyRequestBody(message: String? = null, cause: Throwable? = null) : Exception(message, cause) { + constructor(cause: Throwable) : this(null, cause) + } + + class LoadingFilterFailed(message: String? = null, cause: Throwable? = null) : Exception(message, cause) { + constructor(cause: Throwable) : this(null, cause) + } + + override fun headersBuilder(): Headers.Builder { + if (jwtToken.isEmpty()) { + doLogin() + if (jwtToken.isEmpty()) throw LoginErrorException(helper.intl["login_errors_header_token_empty"]) + } + return Headers.Builder() + .add("User-Agent", "Tachiyomi Kavita v${AppInfo.getVersionName()}") + .add("Content-Type", "application/json") + .add("Authorization", "Bearer $jwtToken") + } + + private fun setupLoginHeaders(): Headers.Builder { + return Headers.Builder() + .add("User-Agent", "Tachiyomi Kavita v${AppInfo.getVersionName()}") + .add("Content-Type", "application/json") + .add("Authorization", "Bearer $jwtToken") + } + + override fun setupPreferenceScreen(screen: androidx.preference.PreferenceScreen) { + val opdsAddressPref = screen.editTextPreference( + ADDRESS_TITLE, + "OPDS url", + "", + helper.intl["pref_opds_summary"], + ) + val enabledFiltersPref = MultiSelectListPreference(screen.context).apply { + key = KavitaConstants.toggledFiltersPref + title = helper.intl["pref_filters_title"] + summary = helper.intl["pref_filters_summary"] + entries = KavitaConstants.filterPrefEntries + entryValues = KavitaConstants.filterPrefEntriesValue + setDefaultValue(KavitaConstants.defaultFilterPrefEntries) + setOnPreferenceChangeListener { _, newValue -> + @Suppress("UNCHECKED_CAST") + val checkValue = newValue as Set + preferences.edit() + .putStringSet(KavitaConstants.toggledFiltersPref, checkValue) + .commit() + } + } + val customSourceNamePref = EditTextPreference(screen.context).apply { + key = KavitaConstants.customSourceNamePref + title = helper.intl["pref_customsource_title"] + summary = helper.intl["pref_edit_customsource_summary"] + setOnPreferenceChangeListener { _, newValue -> + val res = preferences.edit() + .putString(KavitaConstants.customSourceNamePref, newValue.toString()) + .commit() + Toast.makeText( + screen.context, + helper.intl["restartapp_settings"], + Toast.LENGTH_LONG, + ).show() + Log.v(LOG_TAG, "[Preferences] Successfully modified custom source name: $newValue") + res + } + } + screen.addPreference(customSourceNamePref) + screen.addPreference(opdsAddressPref) + screen.addPreference(enabledFiltersPref) + } + + private fun androidx.preference.PreferenceScreen.editTextPreference( + preKey: String, + title: String, + default: String, + summary: String, + isPassword: Boolean = false, + ): EditTextPreference { + return EditTextPreference(context).apply { + key = preKey + this.title = title + val input = preferences.getString(title, null) + this.summary = if (input == null || input.isEmpty()) summary else input + this.setDefaultValue(default) + dialogTitle = title + + if (isPassword) { + setOnBindEditTextListener { + it.inputType = + InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD + } + } + setOnPreferenceChangeListener { _, newValue -> + try { + val opdsUrlInPref = opdsUrlInPreferences(newValue.toString()) // We don't allow hot have multiple sources with same ip or domain + if (opdsUrlInPref.isNotEmpty()) { + // TODO("Add option to allow multiple sources with same url at the cost of tracking") + preferences.edit().putString(title, "").apply() + + Toast.makeText( + context, + helper.intl["pref_opds_duplicated_source_url"] + ": " + opdsUrlInPref, + Toast.LENGTH_LONG, + ).show() + throw OpdsurlExistsInPref(helper.intl["pref_opds_duplicated_source_url"] + opdsUrlInPref) + } + + val res = preferences.edit().putString(title, newValue as String).commit() + Toast.makeText( + context, + helper.intl["restartapp_settings"], + Toast.LENGTH_LONG, + ).show() + setupLogin(newValue) + Log.v(LOG_TAG, "[Preferences] Successfully modified OPDS URL") + res + } catch (e: OpdsurlExistsInPref) { + Log.e(LOG_TAG, "Url exists in a different sourcce") + false + } catch (e: Exception) { + Log.e(LOG_TAG, "Unrecognised error", e) + false + } + } + } + } + + private fun getPrefBaseUrl(): String = preferences.getString("BASEURL", "")!! + private fun getPrefApiUrl(): String = preferences.getString("APIURL", "")!! + private fun getPrefKey(): String = preferences.getString("APIKEY", "")!! + private fun getToggledFilters() = preferences.getStringSet(KavitaConstants.toggledFiltersPref, KavitaConstants.defaultFilterPrefEntries)!! + + // We strip the last slash since we will append it above + private fun getPrefAddress(): String { + var path = preferences.getString(ADDRESS_TITLE, "")!! + if (path.isNotEmpty() && path.last() == '/') { + path = path.substring(0, path.length - 1) + } + return path + } + + private fun getPrefApiKey(): String { + // http(s)://host:(port)/api/opds/api-key + val existingKey = preferences.getString("APIKEY", "") + return existingKey!!.ifEmpty { preferences.getString(ADDRESS_TITLE, "")!!.split("/opds/")[1] } + } + + companion object { + private const val ADDRESS_TITLE = "Address" + private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaTypeOrNull() + } + + /* + * LOGIN + **/ + /** + * Used to check if a url is configured already in any of the sources + * This is a limitation needed for tracking. + * **/ + private fun opdsUrlInPreferences(url: String): String { + fun getCleanedApiUrl(url: String): String = "${url.split("/api/").first()}/api" + + for (sourceId in 1..3) { // There's 3 sources so 3 preferences to check + val sourceSuffixID by lazy { + val key = "${"kavita_$sourceId"}/all/1" // Hardcoded versionID to 1 + 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 + } + val preferences: SharedPreferences by lazy { + Injekt.get().getSharedPreferences("source_$sourceSuffixID", 0x0000) + } + val prefApiUrl = preferences.getString("APIURL", "")!! + + if (prefApiUrl.isNotEmpty()) { + if (prefApiUrl == getCleanedApiUrl(url)) { + if (sourceId.toString() != suffix) { + return preferences.getString(KavitaConstants.customSourceNamePref, sourceId.toString())!! + } + } + } + } + return "" + } + + private fun setupLogin(addressFromPreference: String = "") { + Log.v(LOG_TAG, "[Setup Login] Starting setup") + val validAddress = address.ifEmpty { addressFromPreference } + val tokens = validAddress.split("/api/opds/") + val apiKey = tokens[1] + val baseUrlSetup = tokens[0].replace("\n", "\\n") + + if (baseUrlSetup.toHttpUrlOrNull() == null) { + Log.e(LOG_TAG, "Invalid URL $baseUrlSetup") + throw Exception("""${helper.intl["login_errors_invalid_url"]}: $baseUrlSetup""") + } + preferences.edit().putString("BASEURL", baseUrlSetup).apply() + preferences.edit().putString("APIKEY", apiKey).apply() + preferences.edit().putString("APIURL", "$baseUrlSetup/api").apply() + Log.v(LOG_TAG, "[Setup Login] Setup successful") + } + + private fun doLogin() { + if (address.isEmpty()) { + Log.e(LOG_TAG, "OPDS URL is empty or null") + throw IOException(helper.intl["pref_opds_must_setup_address"]) + } + if (address.split("/opds/").size != 2) { + throw IOException(helper.intl["pref_opds_badformed_url"]) + } + if (jwtToken.isEmpty()) setupLogin() + Log.v(LOG_TAG, "[Login] Starting login") + val request = POST( + "$apiUrl/Plugin/authenticate?apiKey=${getPrefKey()}&pluginName=Tachiyomi-Kavita", + setupLoginHeaders().build(), + "{}".toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull()), + ) + client.newCall(request).execute().use { + val peekbody = it.peekBody(Long.MAX_VALUE).toString() + + if (it.code == 200) { + try { + jwtToken = it.parseAs().token + isLogged = true + } catch (e: Exception) { + Log.e(LOG_TAG, "Possible outdated kavita", e) + throw IOException(helper.intl["login_errors_parse_tokendto"]) + } + } else { + if (it.code == 500) { + Log.e(LOG_TAG, "[LOGIN] login failed. There was some error -> Code: ${it.code}.Response message: ${it.message} Response body: $peekbody.") + throw LoginErrorException(helper.intl["login_errors_failed_login"]) + } else { + Log.e(LOG_TAG, "[LOGIN] login failed. Authentication was not successful -> Code: ${it.code}.Response message: ${it.message} Response body: $peekbody.") + throw LoginErrorException(helper.intl["login_errors_failed_login"]) + } + } + } + Log.v(LOG_TAG, "[Login] Login successful") + } + + init { + if (apiUrl.isNotBlank()) { + Single.fromCallable { + // Login + doLogin() + try { // Get current version + val requestUrl = "$apiUrl/Server/server-info" + val serverInfoDto = client.newCall(GET(requestUrl, headersBuilder().build())) + .execute() + .parseAs() + Log.e( + LOG_TAG, + "Extension version: code=${AppInfo.getVersionCode()} name=${AppInfo.getVersionName()}" + + " - - Kavita version: ${serverInfoDto.kavitaVersion} - - Lang:${Locale.getDefault()}", + ) // this is not a real error. Using this so it gets printed in dump logs if there's any error + } catch (e: EmptyRequestBody) { + Log.e(LOG_TAG, "Extension version: code=${AppInfo.getVersionCode()} - name=${AppInfo.getVersionName()}") + } catch (e: Exception) { + Log.e(LOG_TAG, "Tachiyomi version: code=${AppInfo.getVersionCode()} - name=${AppInfo.getVersionName()}", e) + } + try { // Load Filters + // Genres + Log.v(LOG_TAG, "[Filter] Fetching filters ") + client.newCall(GET("$apiUrl/Metadata/genres", headersBuilder().build())) + .execute().use { response -> + + genresListMeta = try { + response.body.use { json.decodeFromString(it.string()) } + } catch (e: Exception) { + Log.e(LOG_TAG, "[Filter] Error decoding JSON for genres filter -> ${response.body}", e) + emptyList() + } + } + // tagsListMeta + client.newCall(GET("$apiUrl/Metadata/tags", headersBuilder().build())) + .execute().use { response -> + tagsListMeta = try { + response.body.use { json.decodeFromString(it.string()) } + } catch (e: Exception) { + Log.e(LOG_TAG, "[Filter] Error decoding JSON for tagsList filter", e) + emptyList() + } + } + // age-ratings + client.newCall(GET("$apiUrl/Metadata/age-ratings", headersBuilder().build())) + .execute().use { response -> + ageRatingsListMeta = try { + response.body.use { json.decodeFromString(it.string()) } + } catch (e: Exception) { + Log.e( + LOG_TAG, + "[Filter] Error decoding JSON for age-ratings filter", + e, + ) + emptyList() + } + } + // collectionsListMeta + client.newCall(GET("$apiUrl/Collection", headersBuilder().build())) + .execute().use { response -> + collectionsListMeta = try { + response.body.use { json.decodeFromString(it.string()) } + } catch (e: Exception) { + Log.e( + LOG_TAG, + "[Filter] Error decoding JSON for collectionsListMeta filter", + e, + ) + emptyList() + } + } + // languagesListMeta + client.newCall(GET("$apiUrl/Metadata/languages", headersBuilder().build())) + .execute().use { response -> + languagesListMeta = try { + response.body.use { json.decodeFromString(it.string()) } + } catch (e: Exception) { + Log.e( + LOG_TAG, + "[Filter] Error decoding JSON for languagesListMeta filter", + e, + ) + emptyList() + } + } + // libraries + client.newCall(GET("$apiUrl/Library", headersBuilder().build())) + .execute().use { response -> + libraryListMeta = try { + response.body.use { json.decodeFromString(it.string()) } + } catch (e: Exception) { + Log.e( + LOG_TAG, + "[Filter] Error decoding JSON for libraries filter", + e, + ) + emptyList() + } + } + // peopleListMeta + client.newCall(GET("$apiUrl/Metadata/people", headersBuilder().build())) + .execute().use { response -> + peopleListMeta = try { + response.body.use { json.decodeFromString(it.string()) } + } catch (e: Exception) { + Log.e( + LOG_TAG, + "error while decoding JSON for peopleListMeta filter", + e, + ) + emptyList() + } + } + client.newCall(GET("$apiUrl/Metadata/publication-status", headersBuilder().build())) + .execute().use { response -> + pubStatusListMeta = try { + response.body.use { json.decodeFromString(it.string()) } + } catch (e: Exception) { + Log.e( + LOG_TAG, + "error while decoding JSON for publicationStatusListMeta filter", + e, + ) + emptyList() + } + } + client.newCall(GET("$apiUrl/filter", headersBuilder().build())) + .execute().use { response -> + smartFilters = try { + response.body.use { json.decodeFromString(it.string()) } + } catch (e: Exception) { + Log.e( + LOG_TAG, + "error while decoding JSON for smartfilters", + e, + ) + emptyList() + } + } + Log.v(LOG_TAG, "[Filter] Successfully loaded metadata tags from server") + } catch (e: Exception) { + throw LoadingFilterFailed("Failed Loading Filters", e.cause) + } + } + .subscribeOn(Schedulers.io()) + .observeOn(Schedulers.io()) + .subscribe( + {}, + { tr -> + // Avoid polluting logs with traces of exception + if (tr is EmptyRequestBody || tr is LoginErrorException) { + Log.e(LOG_TAG, "error while doing initial calls\n${tr.cause}") + return@subscribe + } + if (tr is ConnectException) { // avoid polluting logs with traces of exception + Log.e(LOG_TAG, "Error while doing initial calls\n${tr.cause}") + return@subscribe + } + Log.e(LOG_TAG, "error while doing initial calls", tr) + }, + ) + } + } +} diff --git a/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/KavitaConstants.kt b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/KavitaConstants.kt new file mode 100644 index 000000000..24f284410 --- /dev/null +++ b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/KavitaConstants.kt @@ -0,0 +1,81 @@ +package eu.kanade.tachiyomi.extension.all.kavita + +object KavitaConstants { + // toggle filters + const val toggledFiltersPref = "toggledFilters" + val filterPrefEntries = arrayOf( + "Sort Options", + "Format", + "Libraries", + "Read Status", + "Genres", + "Tags", + "Collections", + "Languages", + "Publication Status", + "Rating", + "Age Rating", + "Writers", + "Penciller", + "Inker", + "Colorist", + "Letterer", + "Cover Artist", + "Editor", + "Publisher", + "Character", + "Translators", + "ReleaseYearRange", + ) + val filterPrefEntriesValue = arrayOf( + "Sort Options", + "Format", + "Libraries", + "Read Status", + "Genres", + "Tags", + "Collections", + "Languages", + "Publication Status", + "Rating", + "Age Rating", + "Writers", + "Penciller", + "Inker", + "Colorist", + "Letterer", + "CoverArtist", + "Editor", + "Publisher", + "Character", + "Translators", + "ReleaseYearRange", + ) + val defaultFilterPrefEntries = setOf( + "Sort Options", + "Format", + "Libraries", + "Read Status", + "Genres", + "Tags", + "Collections", + "Languages", + "Publication Status", + "Rating", + "Age Rating", + "Writers", + "Penciller", + "Inker", + "Colorist", + "Letterer", + "CoverArtist", + "Editor", + "Publisher", + "Character", + "Translators", + "ReleaseYearRange", + ) + + const val customSourceNamePref = "customSourceName" + const val noSmartFilterSelected = "No smart filter loaded" +} diff --git a/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/KavitaFactory.kt b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/KavitaFactory.kt new file mode 100644 index 000000000..7639b6e53 --- /dev/null +++ b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/KavitaFactory.kt @@ -0,0 +1,13 @@ +package eu.kanade.tachiyomi.extension.all.kavita + +import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.source.SourceFactory + +class KavitaFactory : SourceFactory { + override fun createSources(): List = + listOf( + Kavita("1"), + Kavita("2"), + Kavita("3"), + ) +} diff --git a/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/KavitaHelper.kt b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/KavitaHelper.kt new file mode 100644 index 000000000..f588f684b --- /dev/null +++ b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/KavitaHelper.kt @@ -0,0 +1,141 @@ +package eu.kanade.tachiyomi.extension.all.kavita + +import eu.kanade.tachiyomi.extension.all.kavita.dto.ChapterDto +import eu.kanade.tachiyomi.extension.all.kavita.dto.PaginationInfo +import eu.kanade.tachiyomi.extension.all.kavita.dto.SeriesDto +import eu.kanade.tachiyomi.extension.all.kavita.dto.VolumeDto +import eu.kanade.tachiyomi.lib.i18n.Intl +import eu.kanade.tachiyomi.source.model.SChapter +import eu.kanade.tachiyomi.source.model.SManga +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import okhttp3.Response +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.TimeZone + +class KavitaHelper { + val json = Json { + isLenient = true + ignoreUnknownKeys = true + allowSpecialFloatingPointValues = true + useArrayPolymorphism = true + prettyPrint = true + } + inline fun > safeValueOf(type: String): T { + return java.lang.Enum.valueOf(T::class.java, type) + } + val dateFormatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSSSSSS", Locale.US) + .apply { timeZone = TimeZone.getTimeZone("UTC") } + fun parseDate(dateAsString: String): Long = + dateFormatter.parse(dateAsString)?.time ?: 0 + + fun hasNextPage(response: Response): Boolean { + val paginationHeader = response.header("Pagination") + var hasNextPage = false + if (!paginationHeader.isNullOrEmpty()) { + val paginationInfo = json.decodeFromString(paginationHeader) + hasNextPage = paginationInfo.currentPage + 1 > paginationInfo.totalPages + } + return !hasNextPage + } + + fun getIdFromUrl(url: String): Int { + return url.split("/").last().toInt() + } + + fun createSeriesDto(obj: SeriesDto, baseUrl: String, apiKey: String): SManga = + SManga.create().apply { + url = "$baseUrl/Series/${obj.id}" + title = obj.name + // Deprecated: description = obj.summary + thumbnail_url = "$baseUrl/image/series-cover?seriesId=${obj.id}&apiKey=$apiKey" + } + class CompareChapters { + companion object : Comparator { + override fun compare(a: SChapter, b: SChapter): Int { + if (a.chapter_number < 1.0 && b.chapter_number < 1.0) { + // Both are volumes, multiply by 100 and do normal sort + return if ((a.chapter_number * 100) < (b.chapter_number * 100)) { + 1 + } else { + -1 + } + } else { + if (a.chapter_number < 1.0 && b.chapter_number >= 1.0) { + // A is volume, b is not. A should sort first + return 1 + } else if (a.chapter_number >= 1.0 && b.chapter_number < 1.0) { + return -1 + } + } + if (a.chapter_number < b.chapter_number) return 1 + if (a.chapter_number > b.chapter_number) return -1 + return 0 + } + } + } + fun chapterFromObject(obj: ChapterDto): SChapter = SChapter.create().apply { + url = obj.id.toString() + name = if (obj.number == "0" && obj.isSpecial) { + // This is a special. Chapter name is special name + obj.range + } else { + val cleanedName = obj.title.replaceFirst("^0+(?!$)".toRegex(), "") + "Chapter $cleanedName" + } + date_upload = parseDate(obj.created) + chapter_number = obj.number.toFloat() + scanlator = "${obj.pages} pages" + } + + fun chapterFromVolume(obj: ChapterDto, volume: VolumeDto): SChapter = + SChapter.create().apply { + // If there are multiple chapters to this volume, then prefix with Volume number + if (volume.chapters.isNotEmpty() && obj.number != "0") { + // This volume is not volume 0, hence they are not loose chapters + // We just add a nice Volume X to the chapter title + // Chapter-based Volume + name = "Volume ${volume.number} Chapter ${obj.number}" + chapter_number = obj.number.toFloat() + } else if (obj.number == "0") { + // Both specials and volume has chapter number 0 + if (volume.number == 0) { + // Treat as special + // Special is not in a volume + if (obj.range == "") { + // Special does not have any Title + name = "Chapter 0" + chapter_number = obj.number.toFloat() + } else { + // We use it's own special tile + name = obj.range + chapter_number = obj.number.toFloat() + } + } else { + // Is a single-file volume + // We encode the chapter number to support tracking + name = "Volume ${volume.number}" + chapter_number = volume.number.toFloat() / 10000 + } + } else { + name = "Unhandled Else Volume ${volume.number}" + } + url = obj.id.toString() + date_upload = parseDate(obj.created) + + scanlator = "${obj.pages} pages" + } + val intl = Intl( + language = Locale.getDefault().toString(), + baseLanguage = "en", + availableLanguages = KavitaInt.AVAILABLE_LANGS, + classLoader = this::class.java.classLoader!!, + createMessageFileName = { lang -> + when (lang) { + KavitaInt.SPANISH_LATAM -> Intl.createDefaultMessageFileName(KavitaInt.SPANISH) + else -> Intl.createDefaultMessageFileName(lang) + } + }, + ) +} diff --git a/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/KavitaInt.kt b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/KavitaInt.kt new file mode 100644 index 000000000..fbaf93af0 --- /dev/null +++ b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/KavitaInt.kt @@ -0,0 +1,17 @@ +package eu.kanade.tachiyomi.extension.all.kavita + +object KavitaInt { + const val ENGLISH = "en" + const val SPANISH = "es_ES" + const val SPANISH_LATAM = "es-419" + const val FRENCH = "fr_FR" + const val NORWEGIAN = "nb_NO" + val AVAILABLE_LANGS = setOf( + ENGLISH, + SPANISH, + SPANISH_LATAM, + NORWEGIAN, + FRENCH, + ) + const val KAVITA_NAME = "Kavita" +} diff --git a/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/dto/FilterDto.kt b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/dto/FilterDto.kt new file mode 100644 index 000000000..77b669e51 --- /dev/null +++ b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/dto/FilterDto.kt @@ -0,0 +1,124 @@ +package eu.kanade.tachiyomi.extension.all.kavita.dto + +import kotlinx.serialization.Serializable +import kotlin.Triple + +@Serializable +data class FilterV2Dto( + val id: Int? = null, + val name: String? = null, + val statements: MutableList = mutableListOf(), + val combination: Int = 0, // FilterCombination = FilterCombination.And, + val sortOptions: SortOptions = SortOptions(), + val limitTo: Int = 0, +) { + fun addStatement(comparison: FilterComparison, field: FilterField, value: String) { + if (value.isNotBlank()) { + statements.add(FilterStatementDto(comparison.type, field.type, value)) + } + } + fun addStatement(comparison: FilterComparison, field: FilterField, values: java.util.ArrayList) { + if (values.isNotEmpty()) { + statements.add(FilterStatementDto(comparison.type, field.type, values.joinToString(","))) + } + } + + fun addContainsNotTriple(list: List, ArrayList>>) { + list.map { + addStatement(FilterComparison.Contains, it.first, it.second) + addStatement(FilterComparison.NotContains, it.first, it.third) + } + } + fun addPeople(list: List>>) { + list.map { + addStatement(FilterComparison.MustContains, it.first, it.second) + } + } +} + +@Serializable +data class FilterStatementDto( + // todo: Create custom serializator for comparison and field and remove .type extension in Kavita.kt + val comparison: Int, + val field: Int, + val value: String, + +) + +@Serializable +enum class SortFieldEnum(val type: Int) { + SortName(1), + CreatedDate(2), + LastModifiedDate(3), + LastChapterAdded(4), + TimeToRead(5), + ReleaseYear(6), + ; + + companion object { + private val map = SortFieldEnum.values().associateBy(SortFieldEnum::type) + fun fromInt(type: Int) = map[type] + } +} + +@Serializable +data class SortOptions( + var sortField: Int = SortFieldEnum.SortName.type, + var isAscending: Boolean = true, +) + +@Serializable +enum class FilterCombination { + Or, + And, +} + +@Serializable +enum class FilterField(val type: Int) { + Summary(0), + SeriesName(1), + PublicationStatus(2), + Languages(3), + AgeRating(4), + UserRating(5), + Tags(6), + CollectionTags(7), + Translators(8), + Characters(9), + Publisher(10), + Editor(11), + CoverArtist(12), + Letterer(13), + Colorist(14), + Inker(15), + Penciller(16), + Writers(17), + Genres(18), + Libraries(19), + ReadProgress(20), + Formats(21), + ReleaseYear(22), + ReadTime(23), + Path(24), + FilePath(25), +} + +@Serializable +enum class FilterComparison(val type: Int) { + Equal(0), + GreaterThan(1), + GreaterThanEqual(2), + LessThan(3), + LessThanEqual(4), + Contains(5), + MustContains(6), + Matches(7), + NotContains(8), + NotEqual(9), + BeginsWith(10), + EndsWith(11), + IsBefore(12), + IsAfter(13), + IsInLast(14), + IsNotInLast(15), +} diff --git a/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/dto/MangaDto.kt b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/dto/MangaDto.kt new file mode 100644 index 000000000..1c09db11d --- /dev/null +++ b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/dto/MangaDto.kt @@ -0,0 +1,103 @@ +package eu.kanade.tachiyomi.extension.all.kavita.dto + +import kotlinx.serialization.Serializable + +@Serializable +enum class MangaFormat(val format: Int) { + Image(0), + Archive(1), + Unknown(2), + Epub(3), + Pdf(4), + ; + companion object { + private val map = PersonRole.values().associateBy(PersonRole::role) + fun fromInt(type: Int) = map[type] + } +} +enum class PersonRole(val role: Int) { + Other(1), + Writer(3), + Penciller(4), + Inker(5), + Colorist(6), + Letterer(7), + CoverArtist(8), + Editor(9), + Publisher(10), + Character(11), + Translator(12), + ; + companion object { + private val map = PersonRole.values().associateBy(PersonRole::role) + fun fromInt(type: Int) = map[type] + } +} + +@Serializable +data class SeriesDto( + val id: Int, + val name: String, + val originalName: String = "", + val thumbnail_url: String? = "", + val localizedName: String? = "", + val sortName: String? = "", + val pages: Int, + val coverImageLocked: Boolean = true, + val pagesRead: Int, + val userRating: Float, + val userReview: String? = "", + val format: Int, + val created: String? = "", + val libraryId: Int, + val libraryName: String? = "", +) + +@Serializable +data class SeriesMetadataDto( + val id: Int, + val summary: String? = "", + val writers: List = emptyList(), + val coverArtists: List = emptyList(), + val genres: List = emptyList(), + val seriesId: Int, + val ageRating: Int, + val publicationStatus: Int, +) + +@Serializable +data class Genres( + val title: String, +) + +@Serializable +data class Person( + val name: String, +) + +@Serializable +data class VolumeDto( + val id: Int, + val number: Int, + val name: String, + val pages: Int, + val pagesRead: Int, + val lastModified: String, + val created: String, + val seriesId: Int, + val chapters: List = emptyList(), +) + +@Serializable +data class ChapterDto( + val id: Int, + val range: String, + val number: String, + val pages: Int, + val isSpecial: Boolean, + val title: String, + val pagesRead: Int, + val coverImageLocked: Boolean, + val volumeId: Int, + val created: String, +) diff --git a/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/dto/MetadataDto.kt b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/dto/MetadataDto.kt new file mode 100644 index 000000000..f14256719 --- /dev/null +++ b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/dto/MetadataDto.kt @@ -0,0 +1,104 @@ +package eu.kanade.tachiyomi.extension.all.kavita.dto + +import kotlinx.serialization.Serializable +/** +* This file contains all class for filtering +* */ +@Serializable +data class MetadataGenres( + val id: Int, + val title: String, +) + +@Serializable +data class MetadataPeople( + val id: Int, + val name: String, + val role: Int, +) + +@Serializable +data class MetadataPubStatus( + val value: Int, + val title: String, +) + +@Serializable +data class MetadataTag( + val id: Int, + val title: String, +) + +@Serializable +data class MetadataAgeRatings( + val value: Int, + val title: String, +) + +@Serializable +data class MetadataLanguages( + val isoCode: String, + val title: String, +) + +@Serializable +data class MetadataLibrary( + val id: Int, + val name: String, + val type: Int, +) + +@Serializable +data class MetadataCollections( + val id: Int, + val title: String, +) + +data class MetadataPayload( + val forceUseMetadataPayload: Boolean = true, + var sorting: Int = 1, + var sorting_asc: Boolean = true, + var readStatus: ArrayList = arrayListOf(), + val readStatusList: List = listOf("notRead", "inProgress", "read"), + // _i = included, _e = excluded + var genres_i: ArrayList = arrayListOf(), + var genres_e: ArrayList = arrayListOf(), + var tags_i: ArrayList = arrayListOf(), + var tags_e: ArrayList = arrayListOf(), + var ageRating_i: ArrayList = arrayListOf(), + var ageRating_e: ArrayList = arrayListOf(), + + var formats: ArrayList = arrayListOf(), + var collections_i: ArrayList = arrayListOf(), + var collections_e: ArrayList = arrayListOf(), + var userRating: Int = 0, + var people: ArrayList = arrayListOf(), + // _i = included, _e = excluded + var language_i: ArrayList = arrayListOf(), + var language_e: ArrayList = arrayListOf(), + + var libraries_i: ArrayList = arrayListOf(), + var libraries_e: ArrayList = arrayListOf(), + var pubStatus: ArrayList = arrayListOf(), + var seriesNameQuery: String = "", + var releaseYearRangeMin: Int = 0, + var releaseYearRangeMax: Int = 0, + + var peopleWriters: ArrayList = arrayListOf(), + var peoplePenciller: ArrayList = arrayListOf(), + var peopleInker: ArrayList = arrayListOf(), + var peoplePeoplecolorist: ArrayList = arrayListOf(), + var peopleLetterer: ArrayList = arrayListOf(), + var peopleCoverArtist: ArrayList = arrayListOf(), + var peopleEditor: ArrayList = arrayListOf(), + var peoplePublisher: ArrayList = arrayListOf(), + var peopleCharacter: ArrayList = arrayListOf(), + var peopleTranslator: ArrayList = arrayListOf(), +) + +@Serializable +data class SmartFilter( + val id: Int, + val name: String, + val filter: String, +) diff --git a/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/dto/Responses.kt b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/dto/Responses.kt new file mode 100644 index 000000000..424cc1233 --- /dev/null +++ b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/dto/Responses.kt @@ -0,0 +1,28 @@ +package eu.kanade.tachiyomi.extension.all.kavita.dto + +import kotlinx.serialization.Serializable + +@Serializable // Used to process login +data class AuthenticationDto( + val username: String, + val token: String, + val apiKey: String, +) + +@Serializable +data class PaginationInfo( + val currentPage: Int, + val itemsPerPage: Int, + val totalItems: Int, + val totalPages: Int, +) + +@Serializable +data class ServerInfoDto( + val installId: String, + val os: String, + val isDocker: Boolean, + val dotnetVersion: String, + val kavitaVersion: String, + val numOfCores: Int, +) diff --git a/src/all/komga/AndroidManifest.xml b/src/all/komga/AndroidManifest.xml new file mode 100644 index 000000000..8072ee00d --- /dev/null +++ b/src/all/komga/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/src/all/komga/CHANGELOG.md b/src/all/komga/CHANGELOG.md new file mode 100644 index 000000000..759286187 --- /dev/null +++ b/src/all/komga/CHANGELOG.md @@ -0,0 +1,383 @@ +## 1.4.47 + +Minimum Komga version required: `0.151.0` + +### Feat + +* add support for AVIF and HEIF image types + +## 1.4.46 + +Minimum Komga version required: `0.151.0` + +### Feat + +* Update to extension-lib 1.4 + - Clicking on chapter WebView should now open the chapter/book page. + +## 1.3.45 + +Minimum Komga version required: `0.151.0` + +### Feat + +* Edit source display name + +## 1.3.44 + +Minimum Komga version required: `0.151.0` + +### Fix + +* Better date/time parsing + +## 1.3.43 + +Minimum Komga version required: `0.151.0` + +### Fix + +* Requests failing if address preference is saved with a trailing slash + +### Features + +* Add URL validation in the address preferences +* Use a URL-focused keyboard when available while editing the address preferences + +## 1.3.42 + +Minimum Komga version required: `0.151.0` + +### Fix + +* default sort broken since Komga 0.155.1 +* proper sort criteria for readlists + +## 1.3.41 + +Minimum Komga version required: `0.151.0` + +### Features + +* Improve how the status is displayed + +## 1.3.40 + +Minimum Komga version required: `0.151.0` + +### Features + +* Exclude from bulk update warnings + +## 1.2.39 + +Minimum Komga version required: `0.151.0` + +### Features + +* Prepend series name in front of books within readlists + +## 1.2.38 + +Minimum Komga version required: `0.113.0` + +### Features + +* Add `README.md` + +## 1.2.37 + +Minimum Komga version required: `0.113.0` + +### Features + +* In app link to `CHANGELOG.md` + +## 1.2.36 + +Minimum Komga version required: `0.113.0` + +### Features + +* Don't request conversion for JPEG XL images + +## 1.2.35 + +Minimum Komga version required: `0.113.0` + +### Features + +* Display the Translators of a book in the scanlator chapter field + +## 1.2.34 + +Minimum Komga version required: `0.113.0` + +### Fix + +* Loading of filter values could fail in some cases + +## 1.2.33 + +Minimum Komga version required: `0.113.0` + +### Fix + +* Open in WebView and Share options now open regular browser link instead of showing JSON +* Note that Komga cannot be viewed using System WebView since there is no login prompt + However, opening in a regular browser works. + +## 1.2.32 + +Minimum Komga version required: `0.113.0` + +### Fix + +* Source language, conventionally set to "en", is now changed to "all" +* Downloaded files, if any, will have to be moved to new location + - `Komga (EN)` to `Komga (ALL)` + - `Komga (3) (EN)` to `Komga (3) (ALL)` + +## 1.2.31 + +Minimum Komga version required: `0.113.0` + +### Refactor + +* replace Gson with kotlinx.serialization + +## 1.2.30 + +Minimum Komga version required: `0.113.0` + +### Features + +* display read list summary +* display aggregated tags on series +* search series by book tags + +## 1.2.29 + +Minimum Komga version required: `0.97.0` + +### Features + +* filter deleted series and books + +## 1.2.28 + +Minimum Komga version required: `0.97.0` + +### Fix + +* incorrect User Agent + +## 1.2.27 + +Minimum Komga version required: `0.97.0` + +### Fix + +* filter series by read or in progress + +## 1.2.26 + +Minimum Komga version required: `0.87.4` + +### Fix + +* show series with only in progress books when searching for unread only + +## 1.2.25 + +Minimum Komga version required: `0.87.4` + +### Fix + +* sort order for read list books + +## 1.2.24 + +Minimum Komga version required: `0.87.4` + +### Fix + +* only show series tags in the filter panel +* set URL properly on series and read lists, so restoring from a backup can work properly + + +## 1.2.23 + +Minimum Komga version required: `0.75.0` + +### Features + +* ignore DNS over HTTPS so it can reach IP addresses + +## 1.2.22 + +Minimum Komga version required: `0.75.0` + +### Features + +* add error logs and better catch exceptions + +## 1.2.21 + +Minimum Komga version required: `0.75.0` + +### Features + +* browse read lists (from the filter menu) +* filter by collection, respecting the collection's ordering + +## 1.2.20 + +Minimum Komga version required: `0.75.0` + +### Features + +* filter by authors, grouped by role + +## 1.2.19 + +Minimum Komga version required: `0.68.0` + +### Features + +* display Series authors +* display Series summary from books if no summary exists for Series + +## 1.2.18 + +Minimum Komga version required: `0.63.2` + +### Fix + +* use metadata.releaseDate or fileLastModified for chapter date + +## 1.2.17 + +Minimum Komga version required: `0.63.2` + +### Fix + +* list of collections for filtering could be empty in some conditions + +## 1.2.16 + +Minimum Komga version required: `0.59.0` + +### Features + +* filter by genres, tags and publishers + +## 1.2.15 + +Minimum Komga version required: `0.56.0` + +### Features + +* remove the 1000 chapters limit +* display series description and tags (genres + tags) + +## 1.2.14 + +Minimum Komga version required: `0.41.0` + +### Features + +* change chapter display name to use the display number instead of the sort number + +## 1.2.13 + +Minimum Komga version required: `0.41.0` + +### Features + +* compatibility for the upcoming version of Komga which have changes in the API (IDs are String instead of Long) + +## 1.2.12 + +Minimum Komga version required: `0.41.0` + +### Features + +* filter by collection + +## 1.2.11 + +Minimum Komga version required: `0.35.2` + +### Features + +* Set password preferences inputTypes + +## 1.2.10 + +Minimum Komga version required: `0.35.2` + +### Features + +* unread only filter (closes gotson/komga#180) +* prefix book titles with number (closes gotson/komga#169) + +## 1.2.9 + +Minimum Komga version required: `0.22.0` + +### Features + +* use SourceFactory to have multiple Komga servers (3 for the moment) + +## 1.2.8 + +Minimum Komga version required: `0.22.0` + +### Features + +* use book metadata title for chapter display name +* use book metadata sort number for chapter number + +## 1.2.7 + +### Features + +* use series metadata title for display name +* filter on series status + +## 1.2.6 + +### Features + +* Add support for AndroidX preferences + +## 1.2.5 + +### Features + +* add sort options in filter + +## 1.2.4 + +### Features + +* better handling of authentication + +## 1.2.3 + +### Features + +* filters by library + +## 1.2.2 + +### Features + +* request converted image from server if format is not supported + +## 1.2.1 + +### Features + +* first version diff --git a/src/all/komga/README.md b/src/all/komga/README.md new file mode 100644 index 000000000..fa7ae9fc7 --- /dev/null +++ b/src/all/komga/README.md @@ -0,0 +1,35 @@ +# Komga + +Table of Content +- [FAQ](#FAQ) + - [Why do I see no manga?](#why-do-i-see-no-manga) + - [Where can I get more information about Komga?](#where-can-i-get-more-information-about-komga) + - [The Komga extension stopped working?](#the-komga-extension-stopped-working) + - [Can I add more than one Komga server or user?](#can-i-add-more-than-one-komga-server-or-user) + - [Can I test the Komga extension before setting up my own server?](#can-i-test-the-komga-extension-before-setting-up-my-own-server) +- [Guides](#Guides) + - [How do I add my Komga server to Tachiyomi?](#how-do-i-add-my-komga-server-to-tachiyomi) + +Don't find the question you are look for go check out our general FAQs and Guides over at [Extension FAQ](https://tachiyomi.org/help/faq/#extensions) or [Getting Started](https://tachiyomi.org/help/guides/getting-started/#installation) + +## FAQ + +### Why do I see no manga? +Komga is a self-hosted comic/manga media server. + +### Where can I get more information about Komga? +You can visit the [Komga](https://komga.org/) website for for more information. + +### The Komga extension stopped working? +Make sure that your Komga server and extension are on the newest version. + +### Can I add more than one Komga server or user? +Yes, currently you can add up to 3 different Komga instances to Tachiyomi. + +### Can I test the Komga extension before setting up my own server? +Yes, you can try it out with the DEMO server `https://demo.komga.org`, username `demo@komga.org` and password `komga-demo`. + +## Guides + +### How do I add my Komga server to Tachiyomi? +Go into the settings of the Komga extension from the Extension tab in Browse and fill in your server address and login details. diff --git a/src/all/komga/build.gradle b/src/all/komga/build.gradle new file mode 100644 index 000000000..42e1b089d --- /dev/null +++ b/src/all/komga/build.gradle @@ -0,0 +1,7 @@ +ext { + extName = 'Komga' + extClass = '.KomgaFactory' + extVersionCode = 50 +} + +apply from: "$rootDir/common.gradle" diff --git a/src/all/komga/res/mipmap-hdpi/ic_launcher.png b/src/all/komga/res/mipmap-hdpi/ic_launcher.png new file mode 100755 index 0000000000000000000000000000000000000000..4a551421857e91c1b81656018f088d18a5b12aa7 GIT binary patch literal 6039 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 literal 0 HcmV?d00001 diff --git a/src/all/komga/res/mipmap-xxhdpi/ic_launcher.png b/src/all/komga/res/mipmap-xxhdpi/ic_launcher.png new file mode 100755 index 0000000000000000000000000000000000000000..80894b00625982b8147cce78795ca23de32a88bb GIT binary patch literal 16331 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

5O5NEV>2H4H|)mAlOP` z6m>Rj5=b#nvL|DLq==;d@Z^-G5_ISlvg|P7#R#V{*8hU~@%~QnwA12$VBvlqf*Ql< zu4gdGW=%)eKx~-%iM(o&!lV*@ltu~^BnBik6`?vCo729^KMCsC2U=u%xnw5}8xVpv z3t8g=BVRg0ZDExqOUFs%3K)3KRxkHI`$jur*d^rIe>_4Z$W^35LP|r0U0{6B9h7xY z@6_}3CJVWp;_m&EfdG*<>A}I*b15Bauvh9KdS*nKOiiB6)}wFf-!(=bjR(VM%_uoKM)93jub!g~+(QK}ah7rbsc3 zuc&*kI_KrC%!c(Eb~AH6zt%4|Opqpzo;g&GuVMc3teJ#6HT-bL@67cFghqd`kJFSw zt%!j3kTWlhYyK$G#!Vwb>zU|_w{}x9V*mWw0!tPWc!T$7x2)ns3HcC z4@|Ef!~Q7x{;zb5OwuLcb#Z340$aGziLe*2FxPt~LJW zRg1$c(SRTb56~-=@ZXG!YyF$76Q57dk@`kb7eFmQJk+cB5tOBrAT!D88!&z>qsdJm z=5P^}*@YjiOd}xNK1Yf&Q%k{qn0|FJX1di~*XdgAy{C(4pRR@oNCli#8o*lAtRUe{ z2^z+CcYvXR3BmN;c&3?nvPaubCeoEx!=t$!zM|j?<*gplqPhYNaH>cDwB_fNi9g3L zC(fuWe|VZwomWEaSHoyhjO!lpZY*?Zw_zS=7IRoD98h2cI05!*?MuTtT<4m7r*RNL2xaVXm*f`4Wvi1AI3q#oAk zKO!`I{bLYB#QM9>+~p7vUN`ShTcOeAl^3;h-k;^x?*{n)i#}{pSdAy)UYIMddu|{W zAZ)FlfYzBA!gq3;wS9E{s>b1i2nRU%x+BCl%dTVlb!SM$Bv4;_nBC~*rL9#Jt%M4x6xry1^)r$|xN2r*9A8S9_% zH0k>_PC#7lBCbb2#vE;68vWlZ%Q9~|H+#Gu`HikVO{kK5_aFJaVvFJ22O%Uk!AX1) ztJ& zx?i0khyGK_8vL%a&x2U0MbG}#ExU8B264FgIN?VtA`f=%+z5^=YgnIYuEY!Qzci%C zzLGWK$C5tg2gIk=^A{8%)p4JrAqN->NyrncQYx7mj+G<}*S_5ecXXPx1_@-AYMPE7 zS@SoZaux+|Oq!wts{Fc;I^3kaafg?E2RnA}FOaqTvXX_>uTaJZSj&Yf7Nhz1?cS=y zk*g~n$?~s=>jh#aKZf6BAb`?6w81CA=zq8=d#8oXT!U!{+aJ{j3^>|u&xvx15l-k{ zLS!C^10QzQnuJ}?)j|e3^?V2Rl)n){q$|(dTXa-31yxTG$!eP|gix)2pySsG-^|XW z9w0F8+BkA+)YxlgWK%~A%lY>f)~9f_MmW z{9PfQ-@f-S=^|!WVHwn{35Jci!Th)eFZ|I{Tm^76q1Xhd|T)IHUc*NzMmgE>Uq_RL$~^sXscME^8PQ$-buK1T+`4Tv$QWOxAYnt>c=C=rSQ%_QtD;N2kMv1Bi^x~dr5Dv>>g8EfP|4=6vK}Dsh5!31pKavkR|RR| zQnB|rPE8%LvnKWGB!?3s^tmBGE`-jSPR4_f$eB7+A`D+#0Y9vN`Hl9LnCfQJ5D`EP zW>UdvpVC%R9>E02-ERN*oZHE+R-*W?JMV%Hfj1!~^vdU|-06CS=1}6E- z^l7l@6L(qK5zVqmf8C;v`JZ13fhRXLMoJwxW3yK__Kw&K&1aJ2?uUmiZ87}atuA%j z57m%GL=;RC1(+Wg%H4&Mtber)+VcUdE{H8yqS&GG0CG;f{#$5fDcd;i$Wc5ZFUH_h zSf^OTIAbV_7o}3+mDruE+(K#!R|Zu@r>!_5JOPF3}^b>L5retyHsQNk5e-B zjx7j~1QdTIKD|->{45Yfz$rMuz89Vie?1_0>L-&viwJf4Dc5r_dqMwog(&?TbpQg6 zGy(<*H}C5T^xd%?Sqi(iT}kY=Fh!5_0}Y5l5v?yeH`k)!SXvWa!i+C5B!hQ^02GFb zAW~8eSOrn-JGr}069y?cs?~GwHU-u~)7d~GJm3U7^LSDNSA9)s7EV~7&4ppi1uE;F ztPX-#wvIp&2Fdu?yG>4Gx~NUqsJ*B>!cwJT;D8xUias9FN=J{d`DJ2k}A$#&sA_dKA@#&mOD)@c6A#Vvtt1 z4eKkmnFegrUBeV>7EI^gJaJEKrQR+H^x0xnDP?LKs5MfVx#i+`S&R$u!n(+PEO2bc zxNHw&Z~sEZXLxu=7>#jBG@id+<+rxR9>f`dAEVWURCcOgGYxH2Tl}Xx_f>xSUN;D;jRbHlBYYdeh0=tb=7c6r zM2u4TJ;PLGMXc=2qoWaC`;D+WxwsqAH%gQ+c30LEjuCyRB#FJ^#Y6hQzi;L^4vO7m z%Wt|uGe%Y{-He5WWk4D4v5#vhLBjg1;z9wU})Gn;7`Q&tY-%3 zqGZdR$w#jfj6E&06|nUMf`>|upJCvA#+Qlw9Bk+_p;?iMFeQ}k%kp0350}DeKxL#h zSC-{=?g*r54_ZKQah52ycz?JV718Fn{=%(@1@^f$~wR;)2;k#A1XR{%vlQrQq z6@uKq<`@*G*|sj1)U6s=Qic}IE+*80x;7oo%~SXyQeSq~&Nr7vn+1i@1?SMVsybe~ zWzc|QOuzi=plTBjPUexwTJjRN!~Cde9@5VUIdVVnP!CEGt!b718FV4vU2fft@GwDnC*UfC z?U)qIEw_9d+xpyTLKPzWl{BpK;x9_|j}{q)h!PU5!$HyJfk<3R;dgB6i3#!jLF)eS z2TY|&X~!{R4CL0_WRv)Ty0fwIwN<#G4$%<^fDK{~w*{|=Q({D1C4fA=7K?sP;#sOx zYCQ%dipeD^by1L}MEobl&l z_C9-8mGQOfz7)>CQ38=(c#>S$-7>S_RR&o8cXH6{nDKa3f{UNTau^}A*PVKn$6~ri z2-oB6Un`{v!GS8iEs02`kHBx*kLtIA~8#mC@_&@oZbSkvHF zVGCu|c351dvkC@u00V$Ni|{AYoEuW^XZHNn z7&A}lDBvJ^g`~9Z82G4grIE~DqPgP1E6*G3w-|2kXFy0fRGi076>~6=5Fd{V>mLb; zmju}lhtA6$uQ6$^8PdY;!$lKY%>(EOtE;0# zb~LGTiHk?yDoI`!kT{hMBvd~>j)MXQ!7QS!&bj1}n7 z@c9t?hDd|zw{i!=WgE@Za%nh5R?KqWSepnX{ht9A-DV|t;K2&V_nxdYJGv13wDN;4 zKchP*WaBZ#q*1iBSGZ>ocW{5*=eZQmWVODNzz-%{8G`~sJ9^aH2H7cl<+=`m~s2eJ7ISVOh`?z%L%gBe+dVXF^S*$Ykq&JXZam8F*@V89{ij+ z;vh*&Uqv~97{9O#9&g>bO!!JWB_MZ!uER`afx(8lE%=)?MZ6FaJZd-eA$m68`-c}CeU8k<`ZzHOSO4AyTZhX(aC}eP z&2Y?KKO*?0!oyr=lgrK`Tp=A8WRfI&$T4E0)BeVe3*`!klYRG80%aC`D-N$3-S>2v z9jn_LmBm06$`-;(#DMw@ceuOnL{SmDMMh`vlho(#GW0GAd9>AFsrNV1^-x-+GvyB( zz-SvR+2FiS=SW#4fZX>LY4}m>c@L{f#FkPf0n0|Z4aZg|S~MdIr+1U9d3Kx&7X6+R zohxDb_E=U`HQxF!LZJIc$^T;k5Y6$sy;NoJ-s`@MGSgkEw}gGq#J5^AJv&NnWMpQm z;X_2(j(amv=T9C6nb8fg_=-CWu~pf~kd3dWpeOWUxY_E2ZC2k-3(=wZ{W#3m^`HgWYb1_PA(73K1$PZkP97lVRl&DAF&#f*QN{M~=xQVW za_0!%a{p%e=DMnw!Cuw*B{}BgFvsUc{_YiA8fSe~E0>_pZZ`RWao6b=>Nk{5qrTh< z*fBZ&wl8M+lV8N*a=S*=k_WYj+c-3uO`?inZv11a72Np8qK(`G&~6qh=l#;G<>DIl zD69YM&K!m@)Wu;1+&1F$TtzJMXpsEu*N?lhaN!RbpC{Za(#kV!z;v6Ml)cO%!Jl`4 z3-MSEsp4jOlzAskpH|kj{p0M5*o@mKt!aghDz@Y0b}WHPzO}cee|U^a=*#wQ}B$X5dGHn*Pv+voQnY-Rj^Rxp-jMj{f7e<|fZ3K7DMB zk21UwG#z=U(XMNN`hI=}ilz}5uC^q^E&T~vpmXA{WB>x- z*6E+@tyGaUX)~L17|5kD&r-4LpJw1fAJROb;B3{x!`~sx8sfPc`2Bdl179O6Tf0Dx zKh$S`i-kVQ=mhU|JHxhy>)iQSr{$xKqx0?UZpm=Sn6E2L1-)1*4WXX*TlCb0FbWo( z${h@dZ2LmcgE{7%B30UxSk?6IoU0t$K8C(&=LFL}i*V4)T-WwYz*Z841HqFu>Bo(@fT126KVoGmfsE==dMJNsfYAd6Q zpPzV6rtuH0GC_6#XJ@c>y4*}>r?E~b&M}p^fce|$b8j=w|Af6xKuM#>e0ac=ly_4s#NpG3?doYgg9bbzll`U&U}87@P&p#P zzmuc*flQ4-9xA+e%pco?VPkeOPpQEQpWi`?BfX&WRLD$Zi@3V8lRW&IOU@P-;b|52`w`3Dp= zPpt8ttq{73bL#j*XRlysnp0O-7WKWzxX%ZwNAt7S!u2;|5qr*q>j7Rwj0HIAJc>0Y zc>)==$fQdh+AjTFXuwm!+b2SvrbQ1;A}G+l`QJdIWuEI8rciqZ=E}zSby7uhzng7+ zZHKSW$9;e3@c5o)+9_p5v1epN#MK=H-FEZOMb`AI(wL)|a7SUoQnomc!EAn5t$@@> zw~=?AWM-0#z@jRC!KfN3nN<2&A?f%GEJ=ki{51j#&b*_DOj!52cO9rVbH~q}mK_iN z{qxE{cW94XarPWk-UDe0G>ZA$h$!Om$~Npy@@$x|!6t528quZemHQqUHPbcT#n>#@ ze6D7boXXUH8>hnMUZfbg&>LF2fyMr-{lkGH_fKrM%b89UrUphWEV1pRI=bBjveD_R zBG|reSIf)jG1AU04X7vUtcnW8D55U3i*<#?vTDWY>~v9Q3TJ4icOb0%W$`|m!uLA=a1Kj25p{?FlJU8-X*W47D>&8nmM8s0<7(!3jlP@AV&(zG^mNwW4cavtr4)o!W=iaF zX=gaZ`K{mcv|TN2%ruxOkVef&Lyyz=p4Y)#IHVobDkyX-=i(VfEOU#SmU=Bk%C-be zm>4g`az^?OTW%L7T^6SS=a(vxUjGEcp8*pYJZVBU$S+zD0#&}7YAo~LS2>K<8RHvyxu_<=SbLXH-9QLLBh2qtbE*WZol$ZGu zo#L+l33mNs&d%+mUm<#Fhz9>8f2MTzfDGx+WLYoSrAU}sq0CAK8s}V$^O0 zpBL*0zm-m%Tb4u%6(9TE0M1e60!gc30QuZjQL4x&MZK_eIu;ue*i=SzQ^b_e#fp8+*$5eayXL)SxOmuc~6X%$ORu^K%XDTfD&Ajn4EsaY|IbcBge2 z_D#a_@PN2ozE~#y6X*$c#rn2-P=sxAGU8}lThoye6NVVRhsl_(Br=h)fe%NU3YA&Z z1jFTJ>oeg9sI54)ZkJHIHL8qrLJgn1Zx;CLKa_n9d-#a*a> zJ$7*q19~TN3wkfbA`}J8Mx>d#oY_Pj;$}S;!C3p;l*hBGA;+akLmwjY@?Yw7^fSzr zrAC&~rLELqqFB_E;ZRD2ImxjSEy0%ZEEA-9`nm7iQaU;WHDrI-ng?yKCjT17r3@~@ zys`3u^w>=T^y*d4uH7#HF7sG>I1;!l8S1eTtoXO!XE@Qc*rXJXgO-=wutTGZk`Ml$ zt0-^5#D1ee(QMp(o-e^3i1D*wbyI9sPXN31xF$YRr9E!(b*AUL-$=M^&bMF&x{mOm z*E6Ss)}w@2xyT~MzBU^8sxk`K;vTMguLYVnKwlx9#u*`4#bqOyd5_KK zo`&qMqp?a@H+3wfiu{|#k|_L}Cd!LKv1DSW1RRYoLC`}4-`a)*{|pA3np9yU+PCJF zWYyr4iGgx^Su^kuylhy}o;&GL^&A)8@?Cp!WAUA{_^4rOf8Kq`d;j|d|GS&@>0-4u z*g*tU_(O^DIO|wuU7-`=Iy5#(Pj7g4b z^y)~Bi``+Fwxqy%GOSpjkrsg?h?O~{PVEMv)sd5jE+X-uOPQ~=GIdTyQ^jA+Kn|K* z$q0H1hLM4x=iHa<7gq~|PD8Vom>7B=-N{^40Co`Rs=QY4{@5AX)N{P~OC|WfeHRr- zBq1)FOf7tg_*S_8XadkXQp9_0e=7H+Zr3*GPQEv^UK8yalg49UWzMO^lF z&>rH1)o2lUQ+vW(V#5N|JrnC+#o;ylMTz;QL}73a4)F3Jin2&h?BB+X4#wo*&! zxk1csR!dXTMRAX)l3QBWXO-|2s`V1IZRoh}C+;n4nO z9vAQjFdp3rf4zCEv9uUEakmw3Sp?Y=m_N@*)s2cIl6c~ zj2+l;m6BMKz3;m#vXWs5uKyvNa<#r)Id=blJY8zt_s?K|Q;@j>P9Mk?OoabAk%X zJjR+oB-bfzQQ{|q8_$aLpg#SkkFN2v2Gx}5zbZE+UTEqo<=Yu+?}r)MmE`T${-7u6sHV|bNs)Yb>=@ymCblR|e*e$M#2NK0A2O{_&IZ`+>k}(H1 zOjCEyIs-h@Bs@YAL>044FiG`$8^sBB(wq; z3f#UC@W2A-U3Bjj(6fe&e}dBZc@yR3k~$W|qeui(iFvQ~2qHNgo-Aa9+=t?6W1?N4 z_*;UDmE@L!LOlm3<_wV=Ny!avO^@<0_3hcL+HIF=29l{{|4qud`rw8zdP&uwaXjlH zM;LEYnYElaqg19RbU` ztD2|f`;wDK3g7OSwCl=MNobgE0y&$}Wq2Kw(*MAlhBzccr|)1_XfuaHC60oMCzl_o zCL)~AM=#?t7)LXnkbJ~s?j3tRr0iF9-*#i4jP3~d2c4IT8WYC6yB0Qijsm`RdDD}n z66!e}`Pgm;R6Yy@Ke8FGVU;Jj#$LdLzr684h(8u{u5F#pDoC|Wc9wtY{a|=ODPxt1 zY=r;tT{IyUamtLGs5yocD350i(-~)bJVe4SJOcCWG|s8GE}1~M{{E$K_cDtEZ?belaTP@_oIE^gN-%&SqZ6R(AVWI$mCD0t|wlANN*S zEat-Z?B>1y^l(n%Y9o}r}&v{Ioe z(VGkP6^$jeh6I{OIqxEfaO@A@=}qKG(KLGb4?Ua@>o>r{ksg5=iNjDcLNCDkyvW`= zD2)Y~2LdF-Gb*?_lSdXtQ$x*E3`~T~!FKphHoNFina(hyHg|-xU!M?2<-LCX@1qKe zh{uvKgOj&SToM)HE;nJ?6nqsSURJE|lW$-a;YO2>q_vp3T#T2-rCPgx?$k$F=YNze zDvhLO^P>^c>e=TssMqmd&XK&jw#+rz?ha(7)je;JmJiyy>ad;60$m%jNb;7GobVqe z)s<=IS}%%)Ks;VZRb)Y1-bd-m&W^AEU7P%22Op>0S-g^cUt)t-z|Kzq?iWzmxA^7! z9a|3nYlYK0&JMR5x3eW3(bF-y)jtBTdb$>Z9jj$=E5U)W8cghV|JK|$X-aGmZ!WJ* zzsiKl!N-!%@06|nX*81)2En2SliypH5m5gpwT6Qp6U*#5Bd&YW zHD;JfL78;^#=KC==%LOQ2oe}Pg4Ao`v~I_Si}{3+q;1%xxk41GP4L;&Zi=xvVgaK5 zf)|u~|2epyo}Ye!t6DH&WZA>Fr%Sa%eQ;!vt~U!W`DPAY8??vBjYUdOeTM7djm% zivK}||7tg*c5GgaH$7b1@kAbof11t$*{xX2jm#W@HooPUwe~U?7FiF1mi}7LmdY42 z!nQl?4)l^oXon+5-;rKK6D|#-m0O)>4v#iF+b`LFDOk-+lMH1=6&}=fYp|dQ^L{d& z&9pV}k(C{9jLtc1(Up#T^>aY1aP&c!q%`}=hGrB{f0wG zjFB!oORXsXTR}UPrd&T-+reNZGKy5fIe&lvkm^oQ$8FL_jiYN+jFkB4GkI&3SS=mm74adK?DFZ@gk&y#<(FpXhmGhL!;2RP=J-(aof>Is( zUL=|;Cu<;n%9nNI2{`=C&lInK&1=%rdm3lCQ1(xajXPc!CcDh#!O@>k55K@%i}GcQ z>TPW!rf`|ejyylfBDYKm9ltmIFL@}EC7V^U+9;54J)9h<+MVpiH`_^7MKO8Y2&=4V zjCy7J}H$#xzbv=eD10c9<6Or85w7C;Sh5K~@n_huyzcz#pT8x90}b zC4|*@sM8%hJ-D2vc4~hGh;1iIF&cIqit-0w={CP})8QOW_>*;>{(CcOVYOTS1K~;R zi#chi70f+xt$gFlP>oXL8I`yHhtN$ZcizlJhi_(@x8=XYE5H?#C49J1S-fBwzaZWg z@IiN>57t(yp&Yjf8TH;daQQ1yL~-7-y^Fv`sjR`U`t6?!jeu@DCe6~|@?fevhmj=m zvN15=jBgGr!EyR#WP!;~vx{vC_we$wqE8Kl(Odef+msl;H7DxN52(=y&5%G@Axoo| zUyd`JLTgLMHRq37)jxh)qLD@?Dw@d2!z5%9$ypP~+MLo?L#Hm#{mSYzm9y?~saiHy zj>+89(&?bg2!S%}BvEYke&zjP6*g5qMiD&%O&LtdjI*0h&c(7SEFFiY-{8Sh^-kO2 zdr)bWQ?iHCx#6CAzWc@A z1qX1UBJ*d*x<*(kZ0b3?*@+9(Or$lM6}!BF0co`hGLF1wb6KLMfQ4aGvk3DMocF-! z@??0j6f3eP5^e!x6|E%%F&X|?xQ+|C?J57e0zw1@*f|FTqlaTI9E0l_!s@h!P`td? zb&r;-l*XJ?M({w_$s|g=DAZjNe`I(k4hM8FHtso$Y;d9oq4+KqAkhNeP(>7(>38Ac zr-}F+`m3QHdy=h;n_o4{Fnv|t(FS8Id89U2!TwKvpXqdD60s*j&f}s=y_x52PfGs@ z=*zjl@8Lq{8n162u9mQD5ma$<}bp${{v*yZ2+?f*P~uBPev+P`5yigQ;`i+8{Nh^?2>;bPY*m z)fwFk)^mwh$`iTgx*G@U+NHaj?G(he)v&y&z{&2r{^^DsGr>@Y0KwMVyVZC!gplFjB9~>CjELl@Q03F%@FH-!|$B|YpNMZAVie~cBmq2k*(u8c0I z9RD;z&}ijhGOfLwjnvSKe?#lcXM_oSA{mVdg!DYX(KDs<8U{+a+Ki$zK9k5a!lqk0 zBd-*q`>mzsdA7UUS!+eG3gB4HRS~kxQpqXO`v2C_Tdim(OTwu7aN3?I{}NWDiVpO3 z$IG8ysa$Y2+L$kNwA3lGJi(2xP7V5+xjE(`TLygaqVivf8_ta>@3wIe z3Rqjrm7qL{ctYU%y;BaNWQO|BYni(#_b){dj?h<)iyKHI*)dM^{FU%neY^S30l02< zps$$E{wO<6$Gc4&B4PqeUR)Q66Hw}G$ODRPx6_7ETwY#te3Q1HL|S&H{e`C6GzB;{ ztnVW)J&!S82Ny^iR6-AbA&Mfos!+v9$u(T&=x77xUIyDR*8@Mt>sXrbw@dr_ zDjOTy&^X$r>`ckm8G(0Mx2esrnsb|J<{)_FCZ2X+MQsDumG7bd<~+NuA`glew`H)o z9+$psTtdekG0fZ4uZ1;>jlqp1gs>2e9A0vqtX?35Bm?h?a)ER>MHUkBH~}X3y#u0a zr>aP|Wwh)IIHve$r3ATiExEw4UER%ABjyW+ehcJd%rdCz)y> zFY#@MPyOVo#6Z$XMLuR8#v7HVQ31z6XGTfVVrAdZB<{ z@tu(~m5I#9)%U0pbDA)|MjG-&d$^7NL!`BJry(kD*Gv_iJ@Ps?!j-CD0Z6Gaq9E8m zD-T(dGtd(GZZ{*J(u^34!!(LbcmKhf*`PGc%a)M}iHlCMxne&py3BdR)u|l+FW;LT zdvKKu+CeF*k6hlEO(S%%1hI97tWB5yXg~*=ydNa2gH(!#g`T;hyg-88*WNp!X8XQS z$I0&XQiE;|?}ejiY?pP`hs?I$q_$?Ku~H5Ez014TLsp_-9~GBB6{!9pz958zOs7cb zcnd;7Kv1tU;R2yq-X2O7VjJhYzEkPseXkZ~$0R7Em<(&0F3VVZ>|H3ysHfVnb-w-3 zkS@|_H-dzAxl{(BST8X}e5TFd=2>0esbvKrRiUDfx{4;Fg{ zeQ4|Ggnv$?1>SRsNw1mPPFv_gxY+F6of*l{q`d!q{PZB_kuAuz$hVz#MN!*2h(5jdX|_e8lCpYHWFZUf}Gu5qx*i1gY}_!%$vgb^op>NA_(ueC$FmT!9#-Jg{f z`(>i3DM@kz;iv->B&>_3jmVLrVusP`>#nn$dahD6yI8#8!GZyS06-A%zyAWf zYtuxP@8wyYc0fG*3M7!H;dhy^8zO-GuiEhUu(tCFllb+;u2Bkqgaze43OKY)KKqG0 z{ulP1Uv41E6pw6HuKzLZv-GR}{{=@GxaKY{Mb|UbBWCSB!M*dw;UC|e5O7a#J=pwq zTZ{ecuLS_hTA|vFR}v$WLjFxKppD}AiOWbOP;5nqyrdBMmUUUtafqt|E~m!IYNd7XWB~d?k8Mk`EepdQYz{KM3c~JU`HI>Q(Y{ z-U!6}kRk$t z+xXukjrskS{y5~-n>^`e&1)c>1b-+Ih0+)RK*`J}O*SEj>?bRUY@4uw7}<=%XHu<} z;$UI*hgda?{9J?#tA-7mApg@SxBcsVWTwi#1Z&)6Fw)M^#lUv%wu@%RqyiVE1A8Rl z|DITn3$FVBJ$eL8@uf+DF}(W9E7EF8VI2zwM&A-8(Fb>6SZG6n`dRD|pieE+iz z0D8V6u2J>&WNdk-1-8#hr>>F^1erZz81cd%uBjtr7PeQmLZ`?6z54->2=g}@4WJ_< zH+cxZ(<~lQI#DXgVS<1YML3`zM*G+7?Diwt;=$AqB+j zJM$Plg?FUfd%SpjJ)s z@`IdVQhq)_(K;ZmRx{JH%lN%E;^p%e+9kdk^;jMS?x+C9rUDbvK`j%-ognKP4^Qib z+wOeSoF0da=q3P(~s~NC73S&YsLQzf(vNnRa0{}^xaSVGd z4{05Qqc6%ZyK$1$F`EV>U5E6{HPWazse6nVM#xzV+Ob4`n z{*nNgxN^O4a@mGRVe&AiR}`fY0X*4WCsB^DIOWkbI;4RJ4Uw*<5>9(@LX&UZeB4Uh ze*CZA73E0&8%enlbzi0TS(uRRaGNk`HC}mZISAuVoAxSJFMSUu9~sA>UMa}R2!o{U zm?j`G{jM%W=xZyrLj?fof7c5t(fLeL@s8B*Arjktxk5;An`U&fFXGV@2E5aW_l;|G)WpFejI&e&&z|5V9B&jbKJG>*Uh-e=oURM`>zx>cfQXP|wX zG;MWdQ!&;ii;$`$K{jc@StWo2p4%F*z6J>M#9{oDT#ITn-U$e_5~32^T~B{FskQHi{`RIQ5v^4@!J{S)fjPEhhS=tH~xGj@EAGH$w1VI zR+ht2Q zx9(6AByfOQBcCZClo=*0B_Vq`jh4xIsjT;v0}X``CV6Lg-$m29%;A8`4y73+rDfGpW56t zB$ax-EtQ(kJvk|kwfB95Hd%@@C?X;BfR4nyB6>NpKg2 zn;QWxD@7z0!@~Fu7$F10p36s84|dR>m*ju^$K&zkZ~bxkh&ec}*Uv~&&-krH{T}hX z&84l->#2tkiMj7N2*BcAL(ps9L>g`I0Jwa`dO6(Yy|zx5%BaMWeIl72ULdeVh2*s% z(qv6J3}rt-c2*76-TNWmPAPrG1b{tE=x*u&f0U%*{Uu%S{I@-^YI_U%XonURSbWD9 zXqD+x%ZRus7nC6KV>K2UJJ9}wD)hQW+79XXFr!X>RTf6Ra5pxWkne?XK$qWe#mKL4 zY|o|0PIYel@w55W&y-ZBqtoO6K}96^xoClTx2~p-_)tR!K#R*(toPi;B~y&A35cH@ z<~Yw85-Z9!UqM8RNR-J^DE9fF=8(0$a%ks5%)NQGY1Eie6rnlz5lvniRvsN56v$hs;Jsl9l%CXY;GR1UV15_fwBxL$Q9(b6{rIJy=)M zr||&bSI2$;H^LF~n8iCn)C^F~WlA#_J5w#x=A#dmVteLtbRv-< z+a*F!)(Dqw?~a3BxfzvF>zrP*q$Gd0WAkDh)9Y8>KRmu2zReL-?w_K=gO%B&c%B?iecYRqF>hl}2TbH2#Pg|1QH&q>6D}F{4fgG{7HLVE>{&VWL;B{k zOOD2sA6cQ$#0c&3v>FSkOQU}&01&n(JO0EX0R@Cq&cxcb%CJ%g>5*cTW9{`1qji>j zkFh!zWUk7qlkmaPJ#gb&!%Lp zFYkyyO4BJYo!P9x=Dtt+QY^jmQ(CoxE%4c7HT#DR0M58QL46!a81U@cRBtv*y#CF_09aG*~SC_hji5x z#-gmjxkDB33-15#){P4Rt#DSY&QdB4Cgj4@SS@&p}9>L-} zK1G*Sx?`Ql?#>!u%Qvpo8a9-)!HDPoiQ;N|Rkfg!U-VsmUgpHp2Q9|!2fmMfIopsD zw)~YL&)HLbXGztJDKsrAFP{}9nC6@Bt%!MzvoFPeMo-%L=ULF4y z?b(07?nA?v8}qXeja#Bg4reAX1Si+;QM6jO(|E{(j<&8}QK@CES__K~!~BU(%nn+t2~f z{IUe1K+Y^);-C#VW6~wJ=k)#y&?9{f-dNNP8w#?hI??SRPGp`P z?~8Q{o2_DC=EU!C?4WJVx<;}FP(D0s?FgJa<0>sZbk44>-)@rMZH&V^Y{Gd%zQ?>R zeKF(L5s8q(`D}ege&p8wssQlb2f!%6BpyqzHe=v{76W*WP~mF2Z$Qpe^(@h-Osngs zv?=@SY4ZR3_AJ0RlfI&<81h9;AnF6yenpLujDx1$PK(Og?g$MNxN1W!&*iNf>pS*N z)U`R=`+p?>FiFN(p9*o0st#&7ZNZGxJaR_=Oy*M=E=2YXM60d(HvwlyXXDfU+sb^4 zAf*LYI<^pS&q=@FrlVKb&qj;az}O@MfQ{;&@5kbwbH-@t5wud91?>8?KLQYIS44jP zsv5ux0B&IeHS<>hK-Rc!l9~c6Rt2MQ9m^UGaLH}a1vEJY!@Q~Ld#82Yxm5)g-~JhL zvdE$oS7AgOnE+sIn^%=m#-{=H2o+_U=|7 z0l+eWi1T^KE4N6=eL)=@*4%EF{5B0JnOeKj>S_!CNF@4DD*({hwnuS@$;9dmS@nn5 zJh(HF5vxR~a6Jre4w?NHl3v==YzL>+YnlCBSGvXH25|h4t(bY$w;BM1aK!7AF=s=6 z#>fGf$oaj%BBbsQ>c|?P&vpKALkB>!OM{0&Rm(Y1;>dA(ma@Rt47f^m1D9>?qLt=h z93n~1Vl25w>@C)&zJ8_y_zIJVe@?q-KF%cjlBo&AG{6f>55;94o)d`iRSm1%5t%44 z6M3EzK~}@{kmVWl`+sEs2nKRauzSr^h+T0zIU-bR2 z3;@6RAq-%*@`df|e3Qmmq_XHL1FKlG zex?AJ{MDIwY{8KMqx)7T{uIeC(_nWp{$BwAs)Q%e8k*-Hu&T3c(c2b$ZJcH;3tLHS z>>F})$V|JI#TPl2iJwg-ne7%sd+l*{F?Vc6l=Oab7+8Ae2dLTlGe*34m-n%uyb--= z<|j7l;r^d3_2nTnMh&2|#hHX=07%rPb!$&(V>ydd1`XZ7Lh->i*9d^fMi1gOz|Ix= zN^_2ER?ElM0Y+s#n~x9zE;=9=rNyO~@#|iJxOBHic_{JZk-4Pp3L@_gjnEhXV4rAK z3eb7>lv;N`5D(CYY|%Hq(#qz_{vFjx7Vnv+5qT%B31d=oC>KVg%2`8HN^VxSBziHt zZTx%$>B&*l#FA*iVBM8SG%Yyw5_r;4##G|vegt-h$(|jn@T};Ba@?CRi7kMjZguJ4za?!ehlyG z%lN*@I{c@`^-Zaz5HFDl3d+QFd9Kj`z-2FQoizY8=dC@&202CDgRnLr+RvAuZxIm1 z=uxhAlZ~8HOwJ)NH9-QCS9^)C6&neBIl)J5Hl>H1pJ21Qcesf|ky?OqQWDwVGKm-qn&QQuspQ)>-P>dRwp zpa3AH#4-S6W?i&wJ;L6l^+aI7kIs&TF&py%B%UtBJ}XfYN~m%K-B82BuynTpt%+&S zf>#c{AihA}Ds6Pgv>pPcFu3{%el}KpGQa#S*^~8~l=|#LqHU34vR__AYQvB{rlg2H zDRV*WQ(BKXfR*a_Vr_>jSdYkHa(n~<0NJ>jnR)K~#i6toY)MlQXo8_>bcS%RNBlo1 zCyN2dC3@(7Vb3tDB>81?i2t1);HQZW8L?`F@^fwb39PJE|Jn3hIre|O{+>{QR$DHp zO^>8KAPK)7Eib^=6G_aN0D$DPH#`6|KkJhj@vN?UYxv)C@+^_5r2SeX31kciQ$?9T zT`~hRm1Gl2`j)~FBE6a(=c-|9P=XD$0RUq%`+U;5 z7H16b_@fsjSlg z=E2&s??8@UlGXtPC}`XK13Gg``?n! zXlRTt`ct%KHWuA~h2#K}kemPj8MVs+aoz)}Em_2S1OVhe0ss*S%4k0H+@X?`SX&F1fWT#Yk zmG#Z4QA&V79Awj}Ci@BUR?jgIkaE7oem1-ts4>LMWTm;15erPn0p z7v>KMff5slP{B?2dG@d_q>@9SvKb^mkdbouBUdE1AAW_Y2_yjMPKH-Cf(!uDv~lTi zkm#Dj&)_eqri;#u5A_YI|I9ds#V69`Ztk(SDCea-YHgenEi$GQR#Ke3C z%b$5hVt!fr*UA2utmIlvlWGQu^#I`uP#~lp{LeGO?S|hR%4!FsG&A??s^a5gQI{89)-L?##^~H9J!^nWEJdi08Rin+Qy0FIiR>krlWD zfAWLT3V=xd!Zi`+WD<-00>PW9AXg_BFrxUKz<|t5rB~A2x!E6bv4KSjA?87X5A`@)xS~~8k^bH67` zZNbb!1ISuHFujoaYRtb*(!ajV4jl-@O0kgo@}ELuG%^35%Zz{4*HsNb{w>0q0tDnw z@msw!e|-Z$HHT!ANN5H5PhtNz0pQPK!9V}I!v2^20Kw?$pYmc?oh5&L`T%ZbmCWFB zxK#apR{-(PpW3D#1fhWH2+KfF!682zr0qb@)Rzz72fhGVn)R}4O z*KVOe699G#nVUY+P$|#^fQC9VP5s&}6lem#ZXt8iM;a;xngGyHXQruNyM+Qx0N5>L zZu&?=r9cw^8tTk6^=r3Kpa}rGh0IMKX{Z$Ve;#BORQl}KV*mgE07*qoM6N<$f?Sga A0{{R3 literal 0 HcmV?d00001 diff --git a/src/all/komga/src/eu/kanade/tachiyomi/extension/all/komga/Komga.kt b/src/all/komga/src/eu/kanade/tachiyomi/extension/all/komga/Komga.kt new file mode 100644 index 000000000..4f0adb59f --- /dev/null +++ b/src/all/komga/src/eu/kanade/tachiyomi/extension/all/komga/Komga.kt @@ -0,0 +1,637 @@ +package eu.kanade.tachiyomi.extension.all.komga + +import android.app.Application +import android.content.SharedPreferences +import android.text.Editable +import android.text.InputType +import android.text.TextWatcher +import android.util.Log +import android.widget.Button +import android.widget.Toast +import androidx.preference.EditTextPreference +import androidx.preference.PreferenceScreen +import eu.kanade.tachiyomi.AppInfo +import eu.kanade.tachiyomi.extension.all.komga.dto.AuthorDto +import eu.kanade.tachiyomi.extension.all.komga.dto.BookDto +import eu.kanade.tachiyomi.extension.all.komga.dto.CollectionDto +import eu.kanade.tachiyomi.extension.all.komga.dto.LibraryDto +import eu.kanade.tachiyomi.extension.all.komga.dto.PageDto +import eu.kanade.tachiyomi.extension.all.komga.dto.PageWrapperDto +import eu.kanade.tachiyomi.extension.all.komga.dto.ReadListDto +import eu.kanade.tachiyomi.extension.all.komga.dto.SeriesDto +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.asObservable +import eu.kanade.tachiyomi.network.await +import eu.kanade.tachiyomi.source.ConfigurableSource +import eu.kanade.tachiyomi.source.UnmeteredSource +import eu.kanade.tachiyomi.source.model.Filter +import eu.kanade.tachiyomi.source.model.FilterList +import eu.kanade.tachiyomi.source.model.MangasPage +import eu.kanade.tachiyomi.source.model.Page +import eu.kanade.tachiyomi.source.model.SChapter +import eu.kanade.tachiyomi.source.model.SManga +import eu.kanade.tachiyomi.source.online.HttpSource +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import okhttp3.Credentials +import okhttp3.Dns +import okhttp3.Headers +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import rx.Observable +import rx.Single +import rx.schedulers.Schedulers +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import uy.kohesive.injekt.injectLazy +import java.security.MessageDigest +import java.util.Locale + +open class Komga(private val suffix: String = "") : ConfigurableSource, UnmeteredSource, HttpSource() { + override fun popularMangaRequest(page: Int): Request = + GET("$baseUrl/api/v1/series?page=${page - 1}&deleted=false&sort=metadata.titleSort,asc", headers) + + override fun popularMangaParse(response: Response): MangasPage = + processSeriesPage(response) + + override fun latestUpdatesRequest(page: Int): Request = + GET("$baseUrl/api/v1/series/latest?page=${page - 1}&deleted=false", headers) + + override fun latestUpdatesParse(response: Response): MangasPage = + processSeriesPage(response) + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val collectionId = (filters.find { it is CollectionSelect } as? CollectionSelect)?.let { + it.values[it.state].id + } + + val type = when { + collectionId != null -> "collections/$collectionId/series" + filters.find { it is TypeSelect }?.state == 1 -> "readlists" + else -> "series" + } + + val url = "$baseUrl/api/v1/$type?search=$query&page=${page - 1}&deleted=false".toHttpUrlOrNull()!!.newBuilder() + + filters.forEach { filter -> + when (filter) { + is UnreadFilter -> { + if (filter.state) { + url.addQueryParameter("read_status", "UNREAD") + url.addQueryParameter("read_status", "IN_PROGRESS") + } + } + is InProgressFilter -> { + if (filter.state) { + url.addQueryParameter("read_status", "IN_PROGRESS") + } + } + is ReadFilter -> { + if (filter.state) { + url.addQueryParameter("read_status", "READ") + } + } + is LibraryGroup -> { + val libraryToInclude = mutableListOf() + filter.state.forEach { content -> + if (content.state) { + libraryToInclude.add(content.id) + } + } + if (libraryToInclude.isNotEmpty()) { + url.addQueryParameter("library_id", libraryToInclude.joinToString(",")) + } + } + is StatusGroup -> { + val statusToInclude = mutableListOf() + filter.state.forEach { content -> + if (content.state) { + statusToInclude.add(content.name.uppercase(Locale.ROOT)) + } + } + if (statusToInclude.isNotEmpty()) { + url.addQueryParameter("status", statusToInclude.joinToString(",")) + } + } + is GenreGroup -> { + val genreToInclude = mutableListOf() + filter.state.forEach { content -> + if (content.state) { + genreToInclude.add(content.name) + } + } + if (genreToInclude.isNotEmpty()) { + url.addQueryParameter("genre", genreToInclude.joinToString(",")) + } + } + is TagGroup -> { + val tagToInclude = mutableListOf() + filter.state.forEach { content -> + if (content.state) { + tagToInclude.add(content.name) + } + } + if (tagToInclude.isNotEmpty()) { + url.addQueryParameter("tag", tagToInclude.joinToString(",")) + } + } + is PublisherGroup -> { + val publisherToInclude = mutableListOf() + filter.state.forEach { content -> + if (content.state) { + publisherToInclude.add(content.name) + } + } + if (publisherToInclude.isNotEmpty()) { + url.addQueryParameter("publisher", publisherToInclude.joinToString(",")) + } + } + is AuthorGroup -> { + val authorToInclude = mutableListOf() + filter.state.forEach { content -> + if (content.state) { + authorToInclude.add(content.author) + } + } + authorToInclude.forEach { + url.addQueryParameter("author", "${it.name},${it.role}") + } + } + is Filter.Sort -> { + var sortCriteria = when (filter.state?.index) { + 0 -> if (type == "series") "metadata.titleSort" else "name" + 1 -> "createdDate" + 2 -> "lastModifiedDate" + else -> "" + } + if (sortCriteria.isNotEmpty()) { + sortCriteria += "," + if (filter.state?.ascending!!) "asc" else "desc" + url.addQueryParameter("sort", sortCriteria) + } + } + else -> {} + } + } + + return GET(url.toString(), headers) + } + + override fun searchMangaParse(response: Response): MangasPage = + processSeriesPage(response) + + override fun fetchMangaDetails(manga: SManga): Observable { + return client.newCall(GET(manga.url, headers)) + .asObservable() + .map { response -> + mangaDetailsParse(response).apply { initialized = true } + } + } + + override fun mangaDetailsRequest(manga: SManga): Request = + GET(manga.url.replaceFirst("api/v1/", "", ignoreCase = true), headers) + + override fun mangaDetailsParse(response: Response): SManga { + return response.body.use { body -> + if (response.fromReadList()) { + val readList = json.decodeFromString(body.string()) + readList.toSManga() + } else { + val series = json.decodeFromString(body.string()) + series.toSManga() + } + } + } + + override fun chapterListRequest(manga: SManga): Request = + GET("${manga.url}/books?unpaged=true&media_status=READY&deleted=false", headers) + + override fun chapterListParse(response: Response): List { + val responseBody = response.body + val page = responseBody.use { json.decodeFromString>(it.string()).content } + + val r = page.mapIndexed { index, book -> + SChapter.create().apply { + chapter_number = if (!response.fromReadList()) book.metadata.numberSort else index + 1F + name = "${if (!response.fromReadList()) "${book.metadata.number} - " else "${book.seriesTitle} ${book.metadata.number}: "}${book.metadata.title} (${book.size})" + url = "$baseUrl/api/v1/books/${book.id}" + scanlator = book.metadata.authors.groupBy({ it.role }, { it.name })["translator"]?.joinToString() + date_upload = book.metadata.releaseDate?.let { parseDate(it) } + ?: parseDateTime(book.fileLastModified) + } + } + return r.sortedByDescending { it.chapter_number } + } + + override fun pageListRequest(chapter: SChapter): Request = + GET("${chapter.url}/pages") + + override fun pageListParse(response: Response): List { + val responseBody = response.body + val pages = responseBody.use { json.decodeFromString>(it.string()) } + return pages.map { + val url = "${response.request.url}/${it.number}" + + if (!supportedImageTypes.contains(it.mediaType)) { + "?convert=png" + } else { + "" + } + Page( + index = it.number - 1, + imageUrl = url, + ) + } + } + + override fun getMangaUrl(manga: SManga) = manga.url.replace("/api/v1", "") + + override fun getChapterUrl(chapter: SChapter) = chapter.url.replace("/api/v1/books", "/book") + + private fun processSeriesPage(response: Response): MangasPage { + val responseBody = response.body + return responseBody.use { body -> + if (response.fromReadList()) { + with(json.decodeFromString>(body.string())) { + MangasPage(content.map { it.toSManga() }, !last) + } + } else { + with(json.decodeFromString>(body.string())) { + MangasPage(content.map { it.toSManga() }, !last) + } + } + } + } + + private fun SeriesDto.toSManga(): SManga = + SManga.create().apply { + title = metadata.title + url = "$baseUrl/api/v1/series/$id" + thumbnail_url = "$url/thumbnail" + status = when { + metadata.status == "ENDED" && metadata.totalBookCount != null && booksCount < metadata.totalBookCount -> SManga.PUBLISHING_FINISHED + metadata.status == "ENDED" -> SManga.COMPLETED + metadata.status == "ONGOING" -> SManga.ONGOING + metadata.status == "ABANDONED" -> SManga.CANCELLED + metadata.status == "HIATUS" -> SManga.ON_HIATUS + else -> SManga.UNKNOWN + } + genre = (metadata.genres + metadata.tags + booksMetadata.tags).distinct().joinToString(", ") + description = metadata.summary.ifBlank { booksMetadata.summary } + booksMetadata.authors.groupBy { it.role }.let { map -> + author = map["writer"]?.map { it.name }?.distinct()?.joinToString() + artist = map["penciller"]?.map { it.name }?.distinct()?.joinToString() + } + } + + private fun ReadListDto.toSManga(): SManga = + SManga.create().apply { + title = name + description = summary + url = "$baseUrl/api/v1/readlists/$id" + thumbnail_url = "$url/thumbnail" + status = SManga.UNKNOWN + } + + private fun Response.fromReadList() = request.url.toString().contains("/api/v1/readlists") + + private fun parseDate(date: String?): Long = + if (date == null) { + 0 + } else { + try { + KomgaHelper.formatterDate.parse(date)?.time ?: 0 + } catch (ex: Exception) { + 0 + } + } + + private fun parseDateTime(date: String?): Long = + if (date == null) { + 0 + } else { + try { + KomgaHelper.formatterDateTime.parse(date)?.time ?: 0 + } catch (ex: Exception) { + try { + KomgaHelper.formatterDateTimeMilli.parse(date)?.time ?: 0 + } catch (ex: Exception) { + 0 + } + } + } + + override fun imageUrlParse(response: Response): String = "" + + private class TypeSelect : Filter.Select("Search for", arrayOf(TYPE_SERIES, TYPE_READLISTS)) + private class LibraryFilter(val id: String, name: String) : Filter.CheckBox(name, false) + private class LibraryGroup(libraries: List) : Filter.Group("Libraries", libraries) + private class CollectionSelect(collections: List) : Filter.Select("Collection", collections.toTypedArray()) + private class SeriesSort : Filter.Sort("Sort", arrayOf("Alphabetically", "Date added", "Date updated"), Selection(0, true)) + private class StatusFilter(name: String) : Filter.CheckBox(name, false) + private class StatusGroup(filters: List) : Filter.Group("Status", filters) + private class UnreadFilter : Filter.CheckBox("Unread", false) + private class InProgressFilter : Filter.CheckBox("In Progress", false) + private class ReadFilter : Filter.CheckBox("Read", false) + private class GenreFilter(genre: String) : Filter.CheckBox(genre, false) + private class GenreGroup(genres: List) : Filter.Group("Genres", genres) + private class TagFilter(tag: String) : Filter.CheckBox(tag, false) + private class TagGroup(tags: List) : Filter.Group("Tags", tags) + private class PublisherFilter(publisher: String) : Filter.CheckBox(publisher, false) + private class PublisherGroup(publishers: List) : Filter.Group("Publishers", publishers) + private class AuthorFilter(val author: AuthorDto) : Filter.CheckBox(author.name, false) + private class AuthorGroup(role: String, authors: List) : Filter.Group(role, authors) + + private data class CollectionFilterEntry( + val name: String, + val id: String? = null, + ) { + override fun toString() = name + } + + override fun getFilterList(): FilterList { + val filters = try { + mutableListOf>( + UnreadFilter(), + InProgressFilter(), + ReadFilter(), + TypeSelect(), + CollectionSelect(listOf(CollectionFilterEntry("None")) + collections.map { CollectionFilterEntry(it.name, it.id) }), + LibraryGroup(libraries.map { LibraryFilter(it.id, it.name) }.sortedBy { it.name.lowercase(Locale.ROOT) }), + StatusGroup(listOf("Ongoing", "Ended", "Abandoned", "Hiatus").map { StatusFilter(it) }), + GenreGroup(genres.map { GenreFilter(it) }), + TagGroup(tags.map { TagFilter(it) }), + PublisherGroup(publishers.map { PublisherFilter(it) }), + ).also { list -> + list.addAll(authors.map { (role, authors) -> AuthorGroup(role, authors.map { AuthorFilter(it) }) }) + list.add(SeriesSort()) + } + } catch (e: Exception) { + Log.e(LOG_TAG, "error while creating filter list", e) + emptyList() + } + + return FilterList(filters) + } + + private var libraries = emptyList() + private var collections = emptyList() + private var genres = emptySet() + private var tags = emptySet() + private var publishers = emptySet() + private var authors = emptyMap>() // roles to list of authors + + // keep the previous ID when lang was "en", so that preferences and manga bindings are not lost + override val id by lazy { + val key = "komga${if (suffix.isNotBlank()) " ($suffix)" else ""}/en/$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 + } + + private val displayName by lazy { preferences.displayName } + final override val baseUrl by lazy { preferences.baseUrl } + private val username by lazy { preferences.username } + private val password by lazy { preferences.password } + private val json: Json by injectLazy() + + override fun headersBuilder(): Headers.Builder = + Headers.Builder() + .add("User-Agent", "TachiyomiKomga/${AppInfo.getVersionName()}") + + private val preferences: SharedPreferences by lazy { + Injekt.get().getSharedPreferences("source_$id", 0x0000) + } + + override val name = "Komga${displayName.ifBlank { suffix }.let { if (it.isNotBlank()) " ($it)" else "" }}" + override val lang = "all" + override val supportsLatest = true + private val LOG_TAG = "extension.all.komga${if (suffix.isNotBlank()) ".$suffix" else ""}" + + override val client: OkHttpClient = + network.client.newBuilder() + .authenticator { _, response -> + if (response.request.header("Authorization") != null) { + null // Give up, we've already failed to authenticate. + } else { + response.request.newBuilder() + .addHeader("Authorization", Credentials.basic(username, password)) + .build() + } + } + .dns(Dns.SYSTEM) // don't use DNS over HTTPS as it breaks IP addressing + .build() + + override fun setupPreferenceScreen(screen: PreferenceScreen) { + screen.addEditTextPreference( + title = "Source display name", + default = suffix, + summary = displayName.ifBlank { "Here you can change the source displayed suffix" }, + key = PREF_DISPLAYNAME, + ) + screen.addEditTextPreference( + title = "Address", + default = ADDRESS_DEFAULT, + summary = baseUrl.ifBlank { "The server address" }, + inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI, + validate = { it.toHttpUrlOrNull() != null }, + validationMessage = "The URL is invalid or malformed", + key = PREF_ADDRESS, + ) + screen.addEditTextPreference( + title = "Username", + default = USERNAME_DEFAULT, + summary = username.ifBlank { "The user account email" }, + key = PREF_USERNAME, + ) + screen.addEditTextPreference( + title = "Password", + default = PASSWORD_DEFAULT, + summary = if (password.isBlank()) "The user account password" else "*".repeat(password.length), + inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD, + key = PREF_PASSWORD, + ) + } + + private fun PreferenceScreen.addEditTextPreference( + title: String, + default: String, + summary: String, + inputType: Int? = null, + validate: ((String) -> Boolean)? = null, + validationMessage: String? = null, + key: String = title, + ) { + val preference = EditTextPreference(context).apply { + this.key = key + this.title = title + this.summary = summary + this.setDefaultValue(default) + dialogTitle = title + + setOnBindEditTextListener { editText -> + if (inputType != null) { + editText.inputType = inputType + } + + if (validate != null) { + editText.addTextChangedListener( + object : TextWatcher { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} + + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} + + override fun afterTextChanged(editable: Editable?) { + requireNotNull(editable) + + val text = editable.toString() + + val isValid = text.isBlank() || validate(text) + + editText.error = if (!isValid) validationMessage else null + editText.rootView.findViewById