├── .gitignore ├── .gitmodules ├── .idea └── codeStyles │ ├── Project.xml │ └── codeStyleConfig.xml ├── Dockerfile ├── LICENSE ├── README.md ├── build.gradle.kts ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── komf-api-models ├── build.gradle.kts └── src │ └── commonMain │ └── kotlin │ └── snd │ └── komf │ └── api │ ├── CommonTypes.kt │ ├── KomfErrorResponse.kt │ ├── KomfPage.kt │ ├── PatchValue.kt │ ├── config │ ├── KomfConfig.kt │ └── KomfConfigUpdateRequest.kt │ ├── job │ ├── KomfMetadataJob.kt │ └── KomfMetadataJobEvents.kt │ ├── mediaserver │ ├── KomfMediaServerConnectionResponse.kt │ └── KomfMediaServerLibrary.kt │ ├── metadata │ ├── KomfIdentifyRequest.kt │ ├── KomfMetadataJobResponse.kt │ └── KomfMetadataSearchResult.kt │ └── notifications │ ├── KomfAppriseRenderResult.kt │ ├── KomfAppriseRequest.kt │ ├── KomfAppriseTemplates.kt │ ├── KomfDiscordRenderResult.kt │ ├── KomfDiscordRequest.kt │ ├── KomfDiscordTemplates.kt │ └── KomfNotificationContext.kt ├── komf-app ├── build.gradle.kts └── src │ └── main │ ├── kotlin │ └── snd │ │ └── komf │ │ └── app │ │ ├── AppContext.kt │ │ ├── Application.kt │ │ ├── api │ │ ├── ConfigRoutes.kt │ │ ├── JobRoutes.kt │ │ ├── MediaServerRoutes.kt │ │ ├── MetadataRoutes.kt │ │ ├── NotificationRoutes.kt │ │ ├── deprecated │ │ │ ├── DeprecatedConfigRoutes.kt │ │ │ ├── DeprecatedConfigUpdateMapper.kt │ │ │ ├── DeprecatedMetadataRoutes.kt │ │ │ └── dto │ │ │ │ ├── AppConfigDto.kt │ │ │ │ ├── AppConfigUpdateDto.kt │ │ │ │ └── IdentifySeriesRequest.kt │ │ └── mappers │ │ │ ├── AppConfigMapper.kt │ │ │ ├── AppConfigUpdateMapper.kt │ │ │ └── CommonTypesMapper.kt │ │ ├── config │ │ ├── AppConfig.kt │ │ ├── ConfigLoader.kt │ │ └── ConfigWriter.kt │ │ └── module │ │ ├── MediaServerModule.kt │ │ ├── NotificationsModule.kt │ │ ├── ProvidersModule.kt │ │ └── ServerModule.kt │ └── resources │ ├── apprise_body.vm │ ├── apprise_title.vm │ ├── db │ └── migration │ │ └── sqlite │ │ ├── V1__initial_migration.sql │ │ ├── V2__matched_books.sql │ │ ├── V3__table_indexes.sql │ │ ├── V4__drop_provider_columns.sql │ │ ├── V5__cleanup.sql │ │ ├── V6__server_type_columns.sql │ │ ├── V7__series_match.sql │ │ ├── V8__delete_yen_press_matches.sql │ │ └── V9__delete_kodansha_matches.sql │ ├── description.vm │ └── title.vm ├── komf-client ├── build.gradle.kts └── src │ └── commonMain │ └── kotlin │ └── snd │ └── komf │ └── client │ ├── KomfClientFactory.kt │ ├── KomfConfigClient.kt │ ├── KomfErrorResponse.kt │ ├── KomfJobClient.kt │ ├── KomfMediaServerClient.kt │ ├── KomfMetadataClient.kt │ └── KomfNotificationClient.kt ├── komf-core ├── build.gradle.kts └── src │ ├── commonMain │ └── kotlin │ │ └── snd │ │ └── komf │ │ ├── comicinfo │ │ ├── ComicInfo.kt │ │ └── ComicInfoWriter.kt │ │ ├── ktor │ │ ├── RateLimiter.kt │ │ ├── RateLimiterPlugin.kt │ │ └── UserAgent.kt │ │ ├── model │ │ ├── Author.kt │ │ ├── BookMetadata.kt │ │ ├── Image.kt │ │ ├── MatchQuery.kt │ │ ├── MatchType.kt │ │ ├── MediaType.kt │ │ ├── SeriesMetadata.kt │ │ ├── SeriesSearchResult.kt │ │ ├── UpdateMode.kt │ │ └── WebLink.kt │ │ ├── providers │ │ ├── CoreProviders.kt │ │ ├── MetadataConfigApplier.kt │ │ ├── MetadataProvider.kt │ │ ├── MetadataProvidersConfig.kt │ │ ├── ProviderFactory.kt │ │ ├── anilist │ │ │ ├── AniListClient.kt │ │ │ ├── AniListMetadataMapper.kt │ │ │ ├── AniListMetadataProvider.kt │ │ │ └── model │ │ │ │ ├── AniListMedia.kt │ │ │ │ ├── AniListQuery.kt │ │ │ │ └── AniListResponse.kt │ │ ├── bangumi │ │ │ ├── BangumiClient.kt │ │ │ ├── BangumiMetadataMapper.kt │ │ │ ├── BangumiMetadataProvider.kt │ │ │ └── model │ │ │ │ ├── BangumiSubject.kt │ │ │ │ └── SubjectSearch.kt │ │ ├── bookwalker │ │ │ ├── BookWalkerClient.kt │ │ │ ├── BookWalkerMapper.kt │ │ │ ├── BookWalkerMetadataProvider.kt │ │ │ ├── BookWalkerParser.kt │ │ │ └── model │ │ │ │ ├── BookWalkerBook.kt │ │ │ │ ├── BookWalkerBookListPage.kt │ │ │ │ ├── BookWalkerCategory.kt │ │ │ │ └── BookWalkerSearchResult.kt │ │ ├── comicvine │ │ │ ├── ComicVineClient.kt │ │ │ ├── ComicVineMetadataMapper.kt │ │ │ ├── ComicVineMetadataProvider.kt │ │ │ ├── ComicVineRateLimiter.kt │ │ │ └── model │ │ │ │ ├── ComicVineCredit.kt │ │ │ │ ├── ComicVineImage.kt │ │ │ │ ├── ComicVineIssue.kt │ │ │ │ ├── ComicVineSearchResult.kt │ │ │ │ ├── ComicVineStoryArc.kt │ │ │ │ └── ComicVineVolume.kt │ │ ├── hentag │ │ │ ├── HentagBook.kt │ │ │ ├── HentagClient.kt │ │ │ ├── HentagMetadataMapper.kt │ │ │ └── HentagMetadataProvider.kt │ │ ├── kodansha │ │ │ ├── KodanshaClient.kt │ │ │ ├── KodanshaMetadataMapper.kt │ │ │ ├── KodanshaMetadataProvider.kt │ │ │ └── model │ │ │ │ ├── KodanshaBook.kt │ │ │ │ ├── KodanshaCreator.kt │ │ │ │ ├── KodanshaResponse.kt │ │ │ │ ├── KodanshaSearchResult.kt │ │ │ │ ├── KodanshaSeries.kt │ │ │ │ └── KodanshaThumbnail.kt │ │ ├── mal │ │ │ ├── MalClient.kt │ │ │ ├── MalMetadataMapper.kt │ │ │ ├── MalMetadataProvider.kt │ │ │ └── model │ │ │ │ ├── MalSearchResults.kt │ │ │ │ └── MalSeries.kt │ │ ├── mangabaka │ │ │ ├── MangaBakaClient.kt │ │ │ ├── MangaBakaMetadataMapper.kt │ │ │ ├── MangaBakaMetadataProvider.kt │ │ │ └── model │ │ │ │ ├── MangaBakaSearchResponse.kt │ │ │ │ └── MangaBakaSeries.kt │ │ ├── mangadex │ │ │ ├── MangaDexClient.kt │ │ │ ├── MangaDexMetadataMapper.kt │ │ │ ├── MangaDexMetadataProvider.kt │ │ │ └── model │ │ │ │ ├── MangaDexLink.kt │ │ │ │ ├── MangaDexManga.kt │ │ │ │ └── MangaDexResponse.kt │ │ ├── mangaupdates │ │ │ ├── MangaUpdatesClient.kt │ │ │ ├── MangaUpdatesMetadataMapper.kt │ │ │ ├── MangaUpdatesMetadataProvider.kt │ │ │ └── model │ │ │ │ ├── MangaUpdatesGenre.kt │ │ │ │ ├── MangaUpdatesImage.kt │ │ │ │ ├── MangaUpdatesSearchRequest.kt │ │ │ │ ├── MangaUpdatesSearchResult.kt │ │ │ │ ├── MangaUpdatesSeries.kt │ │ │ │ ├── SearchResultPage.kt │ │ │ │ └── SeriesType.kt │ │ ├── nautiljon │ │ │ ├── NautiljonClient.kt │ │ │ ├── NautiljonMetadataProvider.kt │ │ │ ├── NautiljonParser.kt │ │ │ ├── NautiljonSeriesMetadataMapper.kt │ │ │ └── model │ │ │ │ ├── NautiljonSeries.kt │ │ │ │ ├── NautiljonVolume.kt │ │ │ │ └── SearchResult.kt │ │ ├── viz │ │ │ ├── VizClient.kt │ │ │ ├── VizMetadataMapper.kt │ │ │ ├── VizMetadataProvider.kt │ │ │ ├── VizParser.kt │ │ │ └── model │ │ │ │ ├── AgeRating.kt │ │ │ │ ├── VizAllBooksId.kt │ │ │ │ ├── VizBook.kt │ │ │ │ ├── VizBookReleaseType.kt │ │ │ │ └── VizSeriesBook.kt │ │ └── yenpress │ │ │ ├── YenPressClient.kt │ │ │ ├── YenPressMetadataMapper.kt │ │ │ ├── YenPressMetadataProvider.kt │ │ │ ├── YenPressParser.kt │ │ │ └── model │ │ │ ├── YenPressBook.kt │ │ │ ├── YenPressSearchResult.kt │ │ │ └── YenPressSeriesId.kt │ │ └── util │ │ ├── BookNameParser.kt │ │ ├── ImageHash.kt │ │ ├── NameSimilarityMatcher.kt │ │ ├── OsPlatform.kt │ │ ├── SimpleNaturalComparator.kt │ │ └── StringUtils.kt │ └── jvmMain │ └── kotlin │ └── snd │ └── komf │ └── util │ └── ImageHash.jvm.kt ├── komf-mediaserver ├── build.gradle.kts └── src │ ├── androidMain │ └── kotlin │ │ └── snd │ │ └── komf │ │ └── mediaserver │ │ └── repository │ │ └── Database.android.kt │ ├── commonMain │ ├── kotlin │ │ └── snd │ │ │ └── komf │ │ │ └── mediaserver │ │ │ ├── MediaServerClient.kt │ │ │ ├── MediaServerEventListener.kt │ │ │ ├── MetadataServiceProvider.kt │ │ │ ├── jobs │ │ │ ├── KomfJobTracker.kt │ │ │ ├── KomfJobsRepository.kt │ │ │ └── MetadataJob.kt │ │ │ ├── kavita │ │ │ ├── JwtConsumer.kt │ │ │ ├── KavitaAuthClient.kt │ │ │ ├── KavitaClient.kt │ │ │ ├── KavitaMediaServerClientAdapter.kt │ │ │ ├── KavitaResourceNotFoundException.kt │ │ │ ├── KavitaTokenProvider.kt │ │ │ └── model │ │ │ │ ├── KavitaAgeRating.kt │ │ │ │ ├── KavitaAuthor.kt │ │ │ │ ├── KavitaChapter.kt │ │ │ │ ├── KavitaLibrary.kt │ │ │ │ ├── KavitaSeries.kt │ │ │ │ ├── KavitaVolume.kt │ │ │ │ ├── events │ │ │ │ ├── CoverUpdateEvent.kt │ │ │ │ ├── KavitaEvent.kt │ │ │ │ ├── NotificationProgressEvent.kt │ │ │ │ └── SeriesRemovedEvent.kt │ │ │ │ └── request │ │ │ │ ├── KavitaChapterMetadataUpdateRequest.kt │ │ │ │ ├── KavitaCoverUploadRequest.kt │ │ │ │ ├── KavitaSeriesMetadataUpdateRequest.kt │ │ │ │ └── KavitaSeriesUpdateRequest.kt │ │ │ ├── komga │ │ │ ├── KomgaEventHandler.kt │ │ │ └── KomgaMediaServerClientAdapter.kt │ │ │ ├── metadata │ │ │ ├── MetadataEventHandler.kt │ │ │ ├── MetadataMapper.kt │ │ │ ├── MetadataMerger.kt │ │ │ ├── MetadataPostProcessor.kt │ │ │ ├── MetadataService.kt │ │ │ ├── MetadataUpdater.kt │ │ │ └── repository │ │ │ │ ├── BookThumbnailsRepository.kt │ │ │ │ ├── SeriesMatchRepository.kt │ │ │ │ └── SeriesThumbnailsRepository.kt │ │ │ ├── model │ │ │ ├── MediaServer.kt │ │ │ ├── MediaServerAlternativeTitle.kt │ │ │ ├── MediaServerAuthor.kt │ │ │ ├── MediaServerBook.kt │ │ │ ├── MediaServerBookMetadata.kt │ │ │ ├── MediaServerBookMetadataUpdate.kt │ │ │ ├── MediaServerBookThumbnail.kt │ │ │ ├── MediaServerLibrary.kt │ │ │ ├── MediaServerSeries.kt │ │ │ ├── MediaServerSeriesMetadata.kt │ │ │ ├── MediaServerSeriesMetadataUpdate.kt │ │ │ ├── MediaServerSeriesSearch.kt │ │ │ ├── MediaServerSeriesThumbnail.kt │ │ │ ├── Page.kt │ │ │ └── SeriesAndBookMetadata.kt │ │ │ └── repository │ │ │ └── RepositoryModule.kt │ └── sqldelight │ │ ├── migrations │ │ └── 1.sqm │ │ └── snd │ │ └── komf │ │ └── mediaserver │ │ └── repository │ │ ├── BookThumbnail.sq │ │ ├── KomfJobRecord.sq │ │ ├── SeriesMatch.sq │ │ └── SeriesThumbnail.sq │ └── jvmMain │ └── kotlin │ └── snd │ └── komf │ └── mediaserver │ ├── kavita │ ├── JvmJwtConsumer.kt │ └── KavitaEventHandler.kt │ └── repository │ └── Database.jvm.kt ├── komf-notifications ├── build.gradle.kts └── src │ └── commonMain │ └── kotlin │ └── snd │ └── komf │ └── notifications │ ├── NotificationsEventHandler.kt │ ├── VelocityTemplates.kt │ ├── apprise │ ├── AppriseCliService.kt │ ├── AppriseConfig.kt │ └── AppriseVelocityTemplates.kt │ └── discord │ ├── DiscordConfig.kt │ ├── DiscordVelocityTemplates.kt │ ├── DiscordWebhookService.kt │ └── model │ ├── NotificationContext.kt │ ├── Webhook.kt │ └── WebhookExecuteRequest.kt └── settings.gradle.kts /.gitignore: -------------------------------------------------------------------------------- 1 | .gradle 2 | !gradle/wrapper/gradle-wrapper.jar 3 | build/ 4 | **/build/ 5 | !src/**/build/ 6 | 7 | ### intellij idea ### 8 | .idea/* 9 | !/.idea/codeStyles/ 10 | !/.idea/runConfigurations/ 11 | .idea/modules.xml 12 | .idea/jarrepositories.xml 13 | .idea/compiler.xml 14 | .idea/libraries/ 15 | *.iws 16 | *.iml 17 | *.ipr 18 | out/ 19 | !**/src/main/**/out/ 20 | !**/src/test/**/out/ 21 | 22 | ### eclipse ### 23 | .apt_generated 24 | .classpath 25 | .factorypath 26 | .project 27 | .settings 28 | .springbeans 29 | .sts4-cache 30 | bin/ 31 | !**/src/main/**/bin/ 32 | !**/src/test/**/bin/ 33 | 34 | ### netbeans ### 35 | /nbproject/private/ 36 | /nbbuild/ 37 | /dist/ 38 | /nbdist/ 39 | /.nb-gradle/ 40 | 41 | ### vs code ### 42 | .vscode/ 43 | 44 | ### mac os ### 45 | .ds_store 46 | 47 | database.sqlite -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "Komelia"] 2 | path = Komelia 3 | url = https://github.com/Snd-R/Komelia 4 | -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | 9 | 11 | 12 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM eclipse-temurin:21-jre AS base-amd64 2 | 3 | FROM eclipse-temurin:21-jre AS base-arm64 4 | 5 | FROM eclipse-temurin:17-jre AS base-arm 6 | 7 | FROM base-${TARGETARCH} AS build-final 8 | 9 | RUN apt-get update && apt-get install -y pipx \ 10 | && rm -rf /var/lib/apt/lists/* 11 | 12 | RUN pipx install --include-deps pipx \ 13 | && /root/.local/bin/pipx install --global --include-deps apprise 14 | 15 | WORKDIR /app 16 | COPY komf-app/build/libs/komf-app-1.0.0-SNAPSHOT-all.jar ./ 17 | ENV LC_ALL=en_US.UTF-8 18 | ENV KOMF_CONFIG_DIR="/config" 19 | ENTRYPOINT ["java","-jar", "komf-app-1.0.0-SNAPSHOT-all.jar"] 20 | EXPOSE 8085 21 | 22 | LABEL org.opencontainers.image.url=https://github.com/Snd-R/komf org.opencontainers.image.source=https://github.com/Snd-R/komf 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Snd-R 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | // this is necessary to avoid the plugins to be loaded multiple times 3 | // in each subproject's classloader 4 | alias(libs.plugins.androidLibrary) apply false 5 | alias(libs.plugins.kotlinAtomicfu) apply false 6 | alias(libs.plugins.kotlinAndroid) apply false 7 | alias(libs.plugins.kotlinJvm) apply false 8 | alias(libs.plugins.kotlinMultiplatform) apply false 9 | alias(libs.plugins.mavenPublish) apply false 10 | } 11 | 12 | tasks.wrapper { 13 | gradleVersion = "8.9" 14 | distributionType = Wrapper.DistributionType.ALL 15 | } 16 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | kotlin.code.style=official 2 | 3 | #Gradle 4 | org.gradle.jvmargs=-Dkotlin.daemon.jvm.options\="-Xmx4096M" 5 | 6 | #Android 7 | android.nonTransitiveRClass=true 8 | android.useAndroidX=true 9 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Snd-R/komf/60bb248ccd2297412bac415c650878b043ce4b82/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | @rem SPDX-License-Identifier: Apache-2.0 17 | @rem 18 | 19 | @if "%DEBUG%"=="" @echo off 20 | @rem ########################################################################## 21 | @rem 22 | @rem Gradle startup script for Windows 23 | @rem 24 | @rem ########################################################################## 25 | 26 | @rem Set local scope for the variables with windows NT shell 27 | if "%OS%"=="Windows_NT" setlocal 28 | 29 | set DIRNAME=%~dp0 30 | if "%DIRNAME%"=="" set DIRNAME=. 31 | @rem This is normally unused 32 | set APP_BASE_NAME=%~n0 33 | set APP_HOME=%DIRNAME% 34 | 35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37 | 38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40 | 41 | @rem Find java.exe 42 | if defined JAVA_HOME goto findJavaFromJavaHome 43 | 44 | set JAVA_EXE=java.exe 45 | %JAVA_EXE% -version >NUL 2>&1 46 | if %ERRORLEVEL% equ 0 goto execute 47 | 48 | echo. 1>&2 49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50 | echo. 1>&2 51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52 | echo location of your Java installation. 1>&2 53 | 54 | goto fail 55 | 56 | :findJavaFromJavaHome 57 | set JAVA_HOME=%JAVA_HOME:"=% 58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59 | 60 | if exist "%JAVA_EXE%" goto execute 61 | 62 | echo. 1>&2 63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64 | echo. 1>&2 65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66 | echo location of your Java installation. 1>&2 67 | 68 | goto fail 69 | 70 | :execute 71 | @rem Setup the command line 72 | 73 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 74 | 75 | 76 | @rem Execute Gradle 77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 78 | 79 | :end 80 | @rem End local scope for the variables with windows NT shell 81 | if %ERRORLEVEL% equ 0 goto mainEnd 82 | 83 | :fail 84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 85 | rem the _cmd.exe /c_ return code! 86 | set EXIT_CODE=%ERRORLEVEL% 87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 89 | exit /b %EXIT_CODE% 90 | 91 | :mainEnd 92 | if "%OS%"=="Windows_NT" endlocal 93 | 94 | :omega 95 | -------------------------------------------------------------------------------- /komf-api-models/build.gradle.kts: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalKotlinGradlePluginApi::class) 2 | 3 | import com.vanniktech.maven.publish.SonatypeHost 4 | import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi 5 | import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl 6 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget 7 | 8 | plugins { 9 | alias(libs.plugins.androidLibrary) 10 | alias(libs.plugins.kotlinMultiplatform) 11 | alias(libs.plugins.kotlinSerialization) 12 | alias(libs.plugins.mavenPublish) 13 | signing 14 | } 15 | 16 | group = "io.github.snd-r" 17 | version = libs.versions.app.version.get() 18 | 19 | kotlin { 20 | jvmToolchain(17) 21 | androidTarget { 22 | compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } 23 | publishLibraryVariants("release") 24 | } 25 | jvm { compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } } 26 | @OptIn(ExperimentalWasmDsl::class) 27 | wasmJs { 28 | moduleName = "komf-api-models" 29 | browser() 30 | } 31 | 32 | sourceSets { 33 | commonMain.dependencies { 34 | implementation(libs.kotlinx.serialization.json) 35 | implementation(libs.kotlinx.datetime) 36 | } 37 | } 38 | 39 | } 40 | android { 41 | namespace = "snd.komf" 42 | compileSdk = 35 43 | 44 | defaultConfig { 45 | minSdk = 26 46 | } 47 | compileOptions { 48 | sourceCompatibility = JavaVersion.VERSION_17 49 | targetCompatibility = JavaVersion.VERSION_17 50 | } 51 | 52 | } 53 | 54 | mavenPublishing { 55 | publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL, automaticRelease = false) 56 | coordinates("io.github.snd-r.komf", "api-models", libs.versions.app.version.get()) 57 | signAllPublications() 58 | 59 | pom { 60 | name.set("Komf API models") 61 | description.set("Komf API models") 62 | url.set("https://github.com/Snd-R/komf") 63 | licenses { 64 | license { 65 | name.set("MIT License") 66 | url.set("https://github.com/Snd-R/komf/blob/master/LICENSE") 67 | distribution.set("repo") 68 | } 69 | } 70 | developers { 71 | developer { 72 | id.set("Snd-R") 73 | name.set("Snd-R") 74 | url.set("https://github.com/Snd-R") 75 | } 76 | } 77 | scm { 78 | url.set("https://github.com/Snd-R/komf") 79 | connection.set("scm:git:git://github.com/Snd-R/komf.git") 80 | developerConnection.set("scm:git:ssh://git@github.com/Snd-R/komf.git") 81 | } 82 | } 83 | } 84 | signing { 85 | useGpgCmd() 86 | } 87 | -------------------------------------------------------------------------------- /komf-api-models/src/commonMain/kotlin/snd/komf/api/CommonTypes.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.api 2 | 3 | import kotlinx.serialization.KSerializer 4 | import kotlinx.serialization.Serializable 5 | import kotlinx.serialization.descriptors.PrimitiveKind 6 | import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor 7 | import kotlinx.serialization.descriptors.nullable 8 | import kotlinx.serialization.encoding.Decoder 9 | import kotlinx.serialization.encoding.Encoder 10 | import kotlin.jvm.JvmInline 11 | 12 | enum class KomfAuthorRole { 13 | WRITER, 14 | PENCILLER, 15 | INKER, 16 | COLORIST, 17 | LETTERER, 18 | COVER, 19 | EDITOR, 20 | TRANSLATOR 21 | } 22 | 23 | enum class KomfMediaType { 24 | MANGA, 25 | NOVEL, 26 | COMIC, 27 | } 28 | 29 | enum class KomfNameMatchingMode { 30 | EXACT, 31 | CLOSEST_MATCH, 32 | } 33 | 34 | enum class KomfReadingDirection { 35 | LEFT_TO_RIGHT, 36 | RIGHT_TO_LEFT, 37 | VERTICAL, 38 | WEBTOON 39 | } 40 | 41 | enum class KomfUpdateMode { 42 | API, 43 | COMIC_INFO, 44 | } 45 | 46 | enum class MediaServer { 47 | KOMGA, 48 | KAVITA 49 | } 50 | 51 | 52 | @Serializable(with = KomfProvidersSerializer::class) 53 | sealed interface KomfProviders 54 | enum class KomfCoreProviders : KomfProviders { 55 | ANILIST, 56 | BANGUMI, 57 | BOOK_WALKER, 58 | COMIC_VINE, 59 | HENTAG, 60 | KODANSHA, 61 | MAL, 62 | MANGA_BAKA, 63 | MANGA_UPDATES, 64 | MANGADEX, 65 | NAUTILJON, 66 | YEN_PRESS, 67 | VIZ, 68 | } 69 | 70 | data class UnknownKomfProvider(val name: String) : KomfProviders 71 | 72 | class KomfProvidersSerializer : KSerializer { 73 | override val descriptor = PrimitiveSerialDescriptor("KomfProviders", PrimitiveKind.STRING).nullable 74 | 75 | override fun serialize(encoder: Encoder, value: KomfProviders) { 76 | when (value) { 77 | is KomfCoreProviders -> encoder.encodeString(value.name) 78 | is UnknownKomfProvider -> encoder.encodeString(value.name) 79 | } 80 | } 81 | 82 | override fun deserialize(decoder: Decoder): KomfProviders { 83 | val name = decoder.decodeString() 84 | return runCatching { KomfCoreProviders.valueOf(name) } 85 | .getOrElse { UnknownKomfProvider(name) } 86 | } 87 | } 88 | 89 | enum class MangaDexLink { 90 | MANGA_DEX, 91 | ANILIST, 92 | ANIME_PLANET, 93 | BOOKWALKER_JP, 94 | MANGA_UPDATES, 95 | NOVEL_UPDATES, 96 | KITSU, 97 | AMAZON, 98 | EBOOK_JAPAN, 99 | MY_ANIME_LIST, 100 | CD_JAPAN, 101 | RAW, 102 | ENGLISH_TL, 103 | } 104 | 105 | 106 | @JvmInline 107 | @Serializable 108 | value class KomfServerSeriesId(val value: String) { 109 | override fun toString() = value 110 | } 111 | 112 | @JvmInline 113 | @Serializable 114 | value class KomfServerLibraryId(val value: String) { 115 | override fun toString() = value 116 | } 117 | 118 | @JvmInline 119 | @Serializable 120 | value class KomfProviderSeriesId(val value: String) { 121 | override fun toString() = value 122 | } 123 | -------------------------------------------------------------------------------- /komf-api-models/src/commonMain/kotlin/snd/komf/api/KomfErrorResponse.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.api 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class KomfErrorResponse(val message: String) 7 | 8 | -------------------------------------------------------------------------------- /komf-api-models/src/commonMain/kotlin/snd/komf/api/KomfPage.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.api 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class KomfPage( 7 | val content: T, 8 | val totalPages: Int, 9 | val currentPage: Int, 10 | ) -------------------------------------------------------------------------------- /komf-api-models/src/commonMain/kotlin/snd/komf/api/PatchValue.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.api 2 | 3 | import kotlinx.serialization.ExperimentalSerializationApi 4 | import kotlinx.serialization.KSerializer 5 | import kotlinx.serialization.Serializable 6 | import kotlinx.serialization.SerializationException 7 | import kotlinx.serialization.descriptors.SerialDescriptor 8 | import kotlinx.serialization.encoding.Decoder 9 | import kotlinx.serialization.encoding.Encoder 10 | 11 | @Serializable(PatchValueSerializer::class) 12 | sealed class PatchValue { 13 | data object Unset : PatchValue() 14 | data object None : PatchValue() 15 | class Some(val value: T) : PatchValue() 16 | 17 | fun patch(original: T?, patch: T?): PatchValue { 18 | return when { 19 | original == patch -> Unset 20 | patch == null -> None 21 | else -> Some(patch) 22 | } 23 | } 24 | 25 | fun getOrNull(): T? = when (this) { 26 | None, Unset -> null 27 | is Some -> value 28 | } 29 | } 30 | 31 | class PatchValueSerializer( 32 | private val valueSerializer: KSerializer 33 | ) : KSerializer> { 34 | override val descriptor: SerialDescriptor = valueSerializer.descriptor 35 | 36 | @OptIn(ExperimentalSerializationApi::class) 37 | override fun deserialize(decoder: Decoder): PatchValue { 38 | return when (val value = decoder.decodeNullableSerializableValue(valueSerializer)) { 39 | null -> PatchValue.None 40 | else -> PatchValue.Some(value) 41 | } 42 | } 43 | 44 | @OptIn(ExperimentalSerializationApi::class) 45 | override fun serialize(encoder: Encoder, value: PatchValue) { 46 | when (value) { 47 | PatchValue.None -> encoder.encodeNull() 48 | is PatchValue.Some -> valueSerializer.serialize(encoder, value.value) 49 | PatchValue.Unset -> throw SerializationException("Value is unset. Make sure that property has default unset value") 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /komf-api-models/src/commonMain/kotlin/snd/komf/api/job/KomfMetadataJob.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.api.job 2 | 3 | import kotlinx.datetime.Instant 4 | import kotlinx.serialization.Serializable 5 | import snd.komf.api.KomfServerSeriesId 6 | import kotlin.jvm.JvmInline 7 | 8 | @JvmInline 9 | @Serializable 10 | value class KomfMetadataJobId(val value: String) { 11 | override fun toString() = value 12 | } 13 | 14 | @Serializable 15 | data class KomfMetadataJob( 16 | val seriesId: KomfServerSeriesId, 17 | val id: KomfMetadataJobId, 18 | val status: KomfMetadataJobStatus, 19 | val message: String?, 20 | 21 | val startedAt: Instant, 22 | val finishedAt: Instant?, 23 | ) 24 | 25 | enum class KomfMetadataJobStatus { 26 | RUNNING, 27 | FAILED, 28 | COMPLETED 29 | } 30 | -------------------------------------------------------------------------------- /komf-api-models/src/commonMain/kotlin/snd/komf/api/job/KomfMetadataJobEvents.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.api.job 2 | 3 | import kotlinx.serialization.Serializable 4 | import snd.komf.api.KomfProviders 5 | 6 | const val providerSeriesEventName = "ProviderSeriesEvent" 7 | const val providerBookEventName = "ProviderBookEvent" 8 | const val providerCompletedEventName = "ProviderCompletedEvent" 9 | const val providerErrorEventName = "ProviderErrorEvent" 10 | const val postProcessingStartName = "PostProcessingStartEvent" 11 | const val processingErrorEvent = "ProcessingErrorEvent" 12 | const val eventsStreamNotFoundName = "EventStreamNotFoundEvent" 13 | 14 | @Serializable 15 | sealed interface KomfMetadataJobEvent { 16 | 17 | @Serializable 18 | data class ProviderSeriesEvent( 19 | val provider: KomfProviders, 20 | ) : KomfMetadataJobEvent 21 | 22 | @Serializable 23 | data class ProviderBookEvent( 24 | val provider: KomfProviders, 25 | val totalBooks: Int, 26 | val bookProgress: Int, 27 | ) : KomfMetadataJobEvent 28 | 29 | 30 | @Serializable 31 | data class ProviderErrorEvent( 32 | val provider: KomfProviders, 33 | val message: String 34 | ) : KomfMetadataJobEvent 35 | 36 | @Serializable 37 | data class ProviderCompletedEvent( 38 | val provider: KomfProviders, 39 | ) : KomfMetadataJobEvent 40 | 41 | @Serializable 42 | data object PostProcessingStartEvent : KomfMetadataJobEvent 43 | 44 | @Serializable 45 | data class ProcessingErrorEvent(val message: String) : KomfMetadataJobEvent 46 | 47 | @Serializable 48 | data object NotFound : KomfMetadataJobEvent 49 | 50 | @Serializable 51 | data object UnknownEvent : KomfMetadataJobEvent 52 | } 53 | -------------------------------------------------------------------------------- /komf-api-models/src/commonMain/kotlin/snd/komf/api/mediaserver/KomfMediaServerConnectionResponse.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.api.mediaserver 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class KomfMediaServerConnectionResponse( 7 | val success: Boolean, 8 | val httpStatusCode: Int?, 9 | val errorMessage: String? 10 | ) -------------------------------------------------------------------------------- /komf-api-models/src/commonMain/kotlin/snd/komf/api/mediaserver/KomfMediaServerLibrary.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.api.mediaserver 2 | 3 | import kotlinx.serialization.Serializable 4 | import kotlin.jvm.JvmInline 5 | 6 | @Serializable 7 | data class KomfMediaServerLibrary( 8 | val id: KomfMediaServerLibraryId, 9 | val name: String, 10 | val roots: Collection, 11 | ) 12 | 13 | @JvmInline 14 | @Serializable 15 | value class KomfMediaServerLibraryId(val value: String) 16 | -------------------------------------------------------------------------------- /komf-api-models/src/commonMain/kotlin/snd/komf/api/metadata/KomfIdentifyRequest.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.api.metadata 2 | 3 | import kotlinx.serialization.Serializable 4 | import snd.komf.api.KomfProviderSeriesId 5 | import snd.komf.api.KomfProviders 6 | import snd.komf.api.KomfServerLibraryId 7 | import snd.komf.api.KomfServerSeriesId 8 | 9 | @Serializable 10 | data class KomfIdentifyRequest( 11 | val libraryId: KomfServerLibraryId?, 12 | val seriesId: KomfServerSeriesId, 13 | val provider: KomfProviders, 14 | val providerSeriesId: KomfProviderSeriesId, 15 | ) 16 | -------------------------------------------------------------------------------- /komf-api-models/src/commonMain/kotlin/snd/komf/api/metadata/KomfMetadataJobResponse.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.api.metadata 2 | 3 | import kotlinx.serialization.Serializable 4 | import snd.komf.api.job.KomfMetadataJobId 5 | 6 | @Serializable 7 | data class KomfMetadataJobResponse( 8 | val jobId: KomfMetadataJobId 9 | ) -------------------------------------------------------------------------------- /komf-api-models/src/commonMain/kotlin/snd/komf/api/metadata/KomfMetadataSearchResult.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.api.metadata 2 | 3 | import kotlinx.serialization.Serializable 4 | import snd.komf.api.KomfProviderSeriesId 5 | import snd.komf.api.KomfProviders 6 | 7 | @Serializable 8 | data class KomfMetadataSeriesSearchResult( 9 | val url: String?, 10 | val imageUrl: String? = null, 11 | val title: String, 12 | val provider: KomfProviders, 13 | val resultId: KomfProviderSeriesId, 14 | ) 15 | -------------------------------------------------------------------------------- /komf-api-models/src/commonMain/kotlin/snd/komf/api/notifications/KomfAppriseRenderResult.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.api.notifications 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class KomfAppriseRenderResult( 7 | val title: String?, 8 | val body: String, 9 | ) 10 | 11 | -------------------------------------------------------------------------------- /komf-api-models/src/commonMain/kotlin/snd/komf/api/notifications/KomfAppriseRequest.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.api.notifications 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class KomfAppriseRequest( 7 | val context: KomfNotificationContext = KomfNotificationContext(), 8 | val templates: KomfAppriseTemplates 9 | ) 10 | -------------------------------------------------------------------------------- /komf-api-models/src/commonMain/kotlin/snd/komf/api/notifications/KomfAppriseTemplates.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.api.notifications 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class KomfAppriseTemplates( 7 | val titleTemplate: String? = null, 8 | val bodyTemplate: String? = null, 9 | ) 10 | -------------------------------------------------------------------------------- /komf-api-models/src/commonMain/kotlin/snd/komf/api/notifications/KomfDiscordRenderResult.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.api.notifications 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class KomfDiscordRenderResult( 7 | val title: String?, 8 | val titleUrl: String?, 9 | val description: String?, 10 | val fields: List, 11 | val footer: String?, 12 | ) 13 | 14 | @Serializable 15 | data class EmbedField( 16 | val name: String, 17 | val value: String, 18 | val inline: Boolean, 19 | ) 20 | -------------------------------------------------------------------------------- /komf-api-models/src/commonMain/kotlin/snd/komf/api/notifications/KomfDiscordRequest.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.api.notifications 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class KomfDiscordRequest( 7 | val context: KomfNotificationContext = KomfNotificationContext(), 8 | val templates: KomfDiscordTemplates 9 | ) 10 | -------------------------------------------------------------------------------- /komf-api-models/src/commonMain/kotlin/snd/komf/api/notifications/KomfDiscordTemplates.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.api.notifications 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class KomfDiscordTemplates( 7 | val titleTemplate: String? = null, 8 | val titleUrlTemplate: String? = null, 9 | val descriptionTemplate: String? = null, 10 | val fields: List = emptyList(), 11 | val footerTemplate: String? = null, 12 | ) 13 | 14 | @Serializable 15 | data class EmbedFieldTemplate( 16 | val nameTemplate: String, 17 | val valueTemplate: String, 18 | val inline: Boolean, 19 | ) 20 | -------------------------------------------------------------------------------- /komf-app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget 2 | 3 | plugins { 4 | alias(libs.plugins.kotlinJvm) 5 | alias(libs.plugins.kotlinSerialization) 6 | alias(libs.plugins.shadow) 7 | } 8 | 9 | group = "io.github.snd-r" 10 | version = "1.0.0-SNAPSHOT" 11 | 12 | kotlin { 13 | jvmToolchain(17) 14 | compilerOptions { 15 | jvmTarget.set(JvmTarget.JVM_17) 16 | } 17 | } 18 | java { 19 | targetCompatibility = JavaVersion.VERSION_17 20 | sourceCompatibility = JavaVersion.VERSION_17 21 | toolchain { 22 | languageVersion = JavaLanguageVersion.of(17) 23 | } 24 | } 25 | 26 | dependencies { 27 | implementation(project(":komf-core")) 28 | implementation(project(":komf-mediaserver")) 29 | implementation(project(":komf-notifications")) 30 | implementation(project(":komf-api-models")) 31 | 32 | implementation(libs.logback.core) 33 | implementation(libs.logback.classic) 34 | implementation(libs.slf4j.api) 35 | implementation(libs.kotlin.logging) 36 | 37 | implementation(libs.kotlinx.datetime) 38 | implementation(libs.kotlinx.serialization.json) 39 | implementation(libs.ktor.server.core) 40 | implementation(libs.ktor.server.content.negotiation) 41 | implementation(libs.ktor.server.cio) 42 | implementation(libs.ktor.server.cors) 43 | implementation(libs.ktor.server.default.headers) 44 | implementation(libs.ktor.server.status.pages) 45 | implementation(libs.ktor.server.sse) 46 | implementation(libs.ktor.client.auth) 47 | implementation(libs.ktor.client.core) 48 | implementation(libs.ktor.client.content.negotiation) 49 | implementation(libs.ktor.client.encoding) 50 | implementation(libs.ktor.client.okhttp) 51 | implementation(libs.ktor.serialization.kotlinx.json) 52 | implementation(libs.okhttp) 53 | implementation(libs.okhttp.sse) 54 | implementation(libs.okhttp.logging.interceptor) 55 | implementation(libs.kaml) 56 | } 57 | 58 | tasks { 59 | shadowJar { 60 | manifest { 61 | attributes(Pair("Main-Class", "snd.komf.app.ApplicationKt")) 62 | } 63 | } 64 | } 65 | 66 | tasks.register("depsize") { 67 | description = "Prints dependencies for \"runtime\" configuration" 68 | doLast { 69 | listConfigurationDependencies(configurations["runtimeClasspath"]) 70 | } 71 | } 72 | 73 | fun listConfigurationDependencies(configuration: Configuration) { 74 | val formatStr = "%,10.2f" 75 | 76 | val size = configuration.sumOf { it.length() / (1024.0 * 1024.0) } 77 | 78 | val out = StringBuffer() 79 | out.append("\nConfiguration name: \"${configuration.name}\"\n") 80 | if (size > 0) { 81 | out.append("Total dependencies size:".padEnd(65)) 82 | out.append("${String.format(formatStr, size)} Mb\n\n") 83 | 84 | configuration.sortedBy { -it.length() } 85 | .forEach { 86 | out.append(it.name.padEnd(65)) 87 | out.append("${String.format(formatStr, (it.length() / 1024.0))} kb\n") 88 | } 89 | } else { 90 | out.append("No dependencies found") 91 | } 92 | println(out) 93 | } 94 | -------------------------------------------------------------------------------- /komf-app/src/main/kotlin/snd/komf/app/Application.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.app 2 | 3 | import io.github.oshai.kotlinlogging.KotlinLogging 4 | import java.nio.file.Path 5 | import kotlin.system.exitProcess 6 | 7 | private val logger = KotlinLogging.logger {} 8 | 9 | fun main(vararg args: String) { 10 | runCatching { 11 | val configFile = args.firstOrNull()?.let { Path.of(it) } 12 | val configDir = System.getenv("KOMF_CONFIG_DIR")?.let { Path.of(it) } 13 | AppContext(configDir?: configFile) 14 | }.getOrElse { 15 | logger.error(it) { "Failed to start the app" } 16 | exitProcess(1) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /komf-app/src/main/kotlin/snd/komf/app/api/ConfigRoutes.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.app.api 2 | 3 | import io.github.oshai.kotlinlogging.KotlinLogging 4 | import io.ktor.http.* 5 | import io.ktor.server.request.* 6 | import io.ktor.server.response.* 7 | import io.ktor.server.routing.* 8 | import kotlinx.coroutines.sync.Mutex 9 | import kotlinx.coroutines.sync.withLock 10 | import snd.komf.api.KomfErrorResponse 11 | import snd.komf.api.config.KomfConfigUpdateRequest 12 | import snd.komf.app.AppContext 13 | import snd.komf.app.api.mappers.AppConfigMapper 14 | import snd.komf.app.api.mappers.AppConfigUpdateMapper 15 | 16 | private val logger = KotlinLogging.logger {} 17 | 18 | class ConfigRoutes( 19 | private val appContext: AppContext, 20 | ) { 21 | private val configMapper = AppConfigMapper() 22 | private val updateConfigMapper = AppConfigUpdateMapper() 23 | private val mutex = Mutex() 24 | 25 | fun registerRoutes(routing: Route) { 26 | with(routing) { 27 | getConfigRoute() 28 | updateConfigRoute() 29 | } 30 | } 31 | 32 | private fun Route.getConfigRoute() { 33 | get("/config") { 34 | call.respond(configMapper.toDto(appContext.appConfig)) 35 | } 36 | } 37 | 38 | private fun Route.updateConfigRoute() { 39 | patch("/config") { 40 | val request = call.receive() 41 | mutex.withLock { 42 | val config = updateConfigMapper.patch(appContext.appConfig, request) 43 | try { 44 | appContext.refreshState(config) 45 | } catch (e: Exception) { 46 | logger.catching(e) 47 | call.respond( 48 | HttpStatusCode.UnprocessableEntity, 49 | KomfErrorResponse("${e::class.simpleName}: ${e.message}") 50 | ) 51 | return@patch 52 | } 53 | 54 | } 55 | call.response.status(HttpStatusCode.NoContent) 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /komf-app/src/main/kotlin/snd/komf/app/api/MediaServerRoutes.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.app.api 2 | 3 | import io.ktor.client.plugins.* 4 | import io.ktor.http.* 5 | import io.ktor.server.response.* 6 | import io.ktor.server.routing.* 7 | import kotlinx.coroutines.flow.StateFlow 8 | import snd.komf.api.mediaserver.KomfMediaServerConnectionResponse 9 | import snd.komf.api.mediaserver.KomfMediaServerLibrary 10 | import snd.komf.api.mediaserver.KomfMediaServerLibraryId 11 | import snd.komf.mediaserver.MediaServerClient 12 | 13 | class MediaServerRoutes( 14 | private val mediaServerClient: StateFlow, 15 | ) { 16 | 17 | fun registerRoutes(routing: Route) { 18 | routing.route("/media-server") { 19 | checkConnectionRoute() 20 | getLibrariesRoute() 21 | } 22 | } 23 | 24 | private fun Route.checkConnectionRoute() { 25 | get("/connected") { 26 | try { 27 | mediaServerClient.value.getLibraries() 28 | call.respond( 29 | HttpStatusCode.OK, 30 | KomfMediaServerConnectionResponse( 31 | success = true, 32 | httpStatusCode = HttpStatusCode.OK.value, 33 | errorMessage = null 34 | ) 35 | ) 36 | } catch (exception: ResponseException) { 37 | call.respond( 38 | HttpStatusCode.OK, 39 | KomfMediaServerConnectionResponse( 40 | success = false, 41 | httpStatusCode = exception.response.status.value, 42 | errorMessage = HttpStatusCode.fromValue(exception.response.status.value).description 43 | ) 44 | ) 45 | } catch (exception: Exception) { 46 | call.respond( 47 | HttpStatusCode.OK, 48 | KomfMediaServerConnectionResponse( 49 | success = false, 50 | httpStatusCode = null, 51 | errorMessage = 52 | buildString { 53 | exception.message?.let { append(it) } 54 | exception.cause?.message?.let { append("; $it") } 55 | } 56 | ) 57 | ) 58 | 59 | } 60 | } 61 | } 62 | 63 | private fun Route.getLibrariesRoute() { 64 | get("/libraries") { 65 | val libraries = mediaServerClient.value.getLibraries().map { 66 | KomfMediaServerLibrary( 67 | id = KomfMediaServerLibraryId(it.id.value), 68 | name = it.name, 69 | roots = it.roots 70 | ) 71 | } 72 | call.respond(HttpStatusCode.OK, libraries) 73 | } 74 | } 75 | } -------------------------------------------------------------------------------- /komf-app/src/main/kotlin/snd/komf/app/api/deprecated/DeprecatedConfigRoutes.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.app.api.deprecated 2 | 3 | import io.ktor.http.* 4 | import io.ktor.server.application.* 5 | import io.ktor.server.request.* 6 | import io.ktor.server.response.* 7 | import io.ktor.server.routing.* 8 | import snd.komf.app.AppContext 9 | import snd.komf.app.api.deprecated.dto.AppConfigUpdateDto 10 | 11 | class DeprecatedConfigRoutes( 12 | private val appContext: AppContext, 13 | private val configMapper: DeprecatedConfigUpdateMapper, 14 | ) { 15 | 16 | fun registerRoutes(application: Application) { 17 | application.routing { 18 | getConfigRoute() 19 | updateConfigRoute() 20 | } 21 | } 22 | 23 | private fun Routing.getConfigRoute() { 24 | get("/config") { 25 | val config = configMapper.toDto(appContext.appConfig) 26 | call.respond(config) 27 | } 28 | } 29 | 30 | private fun Routing.updateConfigRoute() { 31 | patch("/config") { 32 | val request = call.receive() 33 | val config = configMapper.patch(appContext.appConfig, request) 34 | 35 | try { 36 | appContext.refreshState(config) 37 | } catch (e: Exception) { 38 | call.respond(HttpStatusCode.UnprocessableEntity, "${e::class.simpleName}: ${e.message}") 39 | return@patch 40 | } 41 | 42 | call.response.status(HttpStatusCode.NoContent) 43 | } 44 | } 45 | } -------------------------------------------------------------------------------- /komf-app/src/main/kotlin/snd/komf/app/api/deprecated/dto/IdentifySeriesRequest.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.app.api.deprecated.dto 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class IdentifySeriesRequest( 7 | val libraryId: String? = null, 8 | val seriesId: String, 9 | val provider: String, 10 | val providerSeriesId: String, 11 | val edition: String? = null 12 | ) 13 | -------------------------------------------------------------------------------- /komf-app/src/main/kotlin/snd/komf/app/config/ConfigWriter.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.app.config 2 | 3 | import com.charleskorn.kaml.Yaml 4 | import java.nio.file.Path 5 | import kotlin.io.path.exists 6 | import kotlin.io.path.isDirectory 7 | import kotlin.io.path.isWritable 8 | import kotlin.io.path.writeText 9 | import kotlin.text.Charsets.UTF_8 10 | 11 | class ConfigWriter(private val yaml: Yaml) { 12 | 13 | @Synchronized 14 | fun writeConfig(config: AppConfig, path: Path) { 15 | checkWriteAccess(path) 16 | if (path.isDirectory()) { 17 | path.resolve("application.yml") 18 | .writeText(yaml.encodeToString(AppConfig.serializer(), config), UTF_8) 19 | } else { 20 | path.writeText(yaml.encodeToString(AppConfig.serializer(), config), UTF_8) 21 | } 22 | } 23 | 24 | @Synchronized 25 | fun writeConfigToDefaultPath(config: AppConfig) { 26 | val filePath = Path.of(".").toAbsolutePath().normalize().resolve("application.yml") 27 | if (filePath.exists()) 28 | checkWriteAccess(filePath) 29 | 30 | filePath.writeText(yaml.encodeToString(AppConfig.serializer(), config), UTF_8) 31 | } 32 | 33 | private fun checkWriteAccess(path: Path) { 34 | if (path.isWritable().not()) throw AccessDeniedException(file = path.toFile(), reason = "No write access to config file") 35 | } 36 | } -------------------------------------------------------------------------------- /komf-app/src/main/kotlin/snd/komf/app/module/NotificationsModule.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.app.module 2 | 3 | import io.ktor.client.* 4 | import io.ktor.client.plugins.* 5 | import io.ktor.client.plugins.contentnegotiation.* 6 | import io.ktor.http.HttpStatusCode.Companion.TooManyRequests 7 | import io.ktor.serialization.kotlinx.json.* 8 | import kotlinx.serialization.json.Json 9 | import snd.komf.app.config.NotificationsConfig 10 | import snd.komf.ktor.HttpRequestRateLimiter 11 | import snd.komf.ktor.komfUserAgent 12 | import snd.komf.notifications.apprise.AppriseCliService 13 | import snd.komf.notifications.apprise.AppriseVelocityTemplates 14 | import snd.komf.notifications.discord.DiscordVelocityTemplates 15 | import snd.komf.notifications.discord.DiscordWebhookService 16 | import kotlin.time.Duration.Companion.seconds 17 | 18 | 19 | class NotificationsModule( 20 | notificationsConfig: NotificationsConfig, 21 | ktorBaseClient: HttpClient, 22 | ) { 23 | private val discordConfig = notificationsConfig.discord 24 | private val appriseConfig = notificationsConfig.apprise 25 | 26 | private val json = Json { 27 | ignoreUnknownKeys = true 28 | encodeDefaults = false 29 | } 30 | private val discordKtorClient = ktorBaseClient.config { 31 | install(HttpRequestRetry) { 32 | retryIf(3) { _, response -> 33 | when (response.status.value) { 34 | TooManyRequests.value -> true 35 | in 500..599 -> true 36 | else -> false 37 | } 38 | } 39 | exponentialDelay(respectRetryAfterHeader = true) 40 | } 41 | install(HttpRequestRateLimiter) { 42 | interval = 2.seconds 43 | eventsPerInterval = 4 44 | allowBurst = false 45 | } 46 | install(UserAgent) { agent = komfUserAgent } 47 | install(ContentNegotiation) { json(json) } 48 | } 49 | 50 | val appriseVelocityRenderer = AppriseVelocityTemplates(notificationsConfig.templatesDirectory) 51 | val appriseService = AppriseCliService( 52 | urls = notificationsConfig.apprise.urls ?: emptyList(), 53 | templateRenderer = appriseVelocityRenderer, 54 | seriesCover = appriseConfig.seriesCover, 55 | ) 56 | 57 | val discordVelocityRenderer = DiscordVelocityTemplates(notificationsConfig.templatesDirectory) 58 | val discordWebhookService = DiscordWebhookService( 59 | ktor = discordKtorClient, 60 | json = json, 61 | templateRenderer = discordVelocityRenderer, 62 | seriesCover = discordConfig.seriesCover, 63 | webhooks = discordConfig.webhooks ?: emptyList(), 64 | embedColor = discordConfig.embedColor, 65 | ) 66 | } -------------------------------------------------------------------------------- /komf-app/src/main/kotlin/snd/komf/app/module/ProvidersModule.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.app.module 2 | 3 | import io.ktor.client.* 4 | import snd.komf.providers.MetadataProvidersConfig 5 | import snd.komf.providers.ProviderFactory 6 | 7 | class ProvidersModule( 8 | providersConfig: MetadataProvidersConfig, 9 | ktorBaseClient: HttpClient 10 | ) { 11 | private val providerFactory = ProviderFactory(ktorBaseClient) 12 | 13 | val metadataProviders = providerFactory.getMetadataProviders(providersConfig) 14 | } -------------------------------------------------------------------------------- /komf-app/src/main/resources/apprise_body.vm: -------------------------------------------------------------------------------- 1 | #if (${series.metadata.summary} != "") 2 | ${series.metadata.summary} 3 | #end 4 | new #if(${books.size()} == 1)book was #{else}books were #{end}added to library ${library.name}: 5 | #foreach ($book in $books) 6 | ${book.name} 7 | #end -------------------------------------------------------------------------------- /komf-app/src/main/resources/apprise_title.vm: -------------------------------------------------------------------------------- 1 | $series.name 2 | -------------------------------------------------------------------------------- /komf-app/src/main/resources/db/migration/sqlite/V1__initial_migration.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE MATCHED_SERIES 2 | ( 3 | SERIES_ID varchar NOT NULL PRIMARY KEY, 4 | THUMBNAIL_ID varchar, 5 | PROVIDER varchar NOT NULL, 6 | PROVIDER_SERIES_ID varchar NOT NULL 7 | ); 8 | -------------------------------------------------------------------------------- /komf-app/src/main/resources/db/migration/sqlite/V2__matched_books.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE MATCHED_BOOKS 2 | ( 3 | BOOK_ID varchar NOT NULL PRIMARY KEY, 4 | SERIES_ID varchar NOT NULL, 5 | THUMBNAIL_ID varchar 6 | ); 7 | -------------------------------------------------------------------------------- /komf-app/src/main/resources/db/migration/sqlite/V3__table_indexes.sql: -------------------------------------------------------------------------------- 1 | CREATE INDEX matched_books_book_id_idx on MATCHED_BOOKS (BOOK_ID); 2 | CREATE INDEX matched_series_series_id_idx on MATCHED_SERIES (SERIES_ID); 3 | -------------------------------------------------------------------------------- /komf-app/src/main/resources/db/migration/sqlite/V4__drop_provider_columns.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE MATCHED_SERIES 2 | RENAME TO MATCHED_SERIES_OLD; 3 | 4 | CREATE TABLE MATCHED_SERIES 5 | ( 6 | SERIES_ID varchar NOT NULL PRIMARY KEY, 7 | THUMBNAIL_ID varchar 8 | ); 9 | 10 | INSERT INTO MATCHED_SERIES (SERIES_ID, THUMBNAIL_ID) 11 | SELECT SERIES_ID, THUMBNAIL_ID 12 | FROM MATCHED_SERIES_OLD; 13 | 14 | DROP TABLE MATCHED_SERIES_OLD 15 | -------------------------------------------------------------------------------- /komf-app/src/main/resources/db/migration/sqlite/V5__cleanup.sql: -------------------------------------------------------------------------------- 1 | DELETE 2 | FROM MATCHED_BOOKS 3 | WHERE THUMBNAIL_ID IS NULL; 4 | 5 | DELETE 6 | FROM MATCHED_SERIES 7 | WHERE THUMBNAIL_ID IS NULL; 8 | -------------------------------------------------------------------------------- /komf-app/src/main/resources/db/migration/sqlite/V6__server_type_columns.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE MATCHED_SERIES 2 | RENAME TO MATCHED_SERIES_OLD; 3 | 4 | CREATE TABLE MATCHED_SERIES 5 | ( 6 | SERIES_ID varchar NOT NULL, 7 | SERVER_TYPE varchar NOT NULL, 8 | THUMBNAIL_ID varchar, 9 | PRIMARY KEY (SERIES_ID, SERVER_TYPE) 10 | ); 11 | 12 | INSERT INTO MATCHED_SERIES (SERIES_ID, THUMBNAIL_ID, SERVER_TYPE) 13 | SELECT SERIES_ID, THUMBNAIL_ID, 'KOMGA' 14 | FROM MATCHED_SERIES_OLD; 15 | 16 | DROP TABLE MATCHED_SERIES_OLD; 17 | 18 | ALTER TABLE MATCHED_BOOKS 19 | RENAME TO MATCHED_BOOKS_OLD; 20 | 21 | CREATE TABLE MATCHED_BOOKS 22 | ( 23 | BOOK_ID varchar NOT NULL, 24 | SERVER_TYPE varchar NOT NULL, 25 | SERIES_ID varchar NOT NULL, 26 | THUMBNAIL_ID varchar, 27 | PRIMARY KEY (BOOK_ID, SERVER_TYPE) 28 | ); 29 | 30 | INSERT INTO MATCHED_BOOKS (BOOK_ID, SERIES_ID, THUMBNAIL_ID, SERVER_TYPE) 31 | SELECT BOOK_ID, SERIES_ID, THUMBNAIL_ID, 'KOMGA' 32 | FROM MATCHED_BOOKS_OLD; 33 | 34 | DROP TABLE MATCHED_BOOKS_OLD; 35 | -------------------------------------------------------------------------------- /komf-app/src/main/resources/db/migration/sqlite/V7__series_match.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE MATCHED_SERIES 2 | RENAME TO SERIES_THUMBNAILS; 3 | 4 | ALTER TABLE MATCHED_BOOKS 5 | RENAME TO BOOK_THUMBNAILS; 6 | 7 | 8 | CREATE TABLE SERIES_MATCH 9 | ( 10 | SERIES_ID varchar NOT NULL, 11 | TYPE varchar NOT NULL, 12 | SERVER_TYPE varchar NOT NULL, 13 | PROVIDER varchar NOT NULL, 14 | PROVIDER_SERIES_ID varchar NOT NULL, 15 | EDITION varchar, 16 | PRIMARY KEY (SERIES_ID, SERVER_TYPE) 17 | ); 18 | -------------------------------------------------------------------------------- /komf-app/src/main/resources/db/migration/sqlite/V8__delete_yen_press_matches.sql: -------------------------------------------------------------------------------- 1 | DELETE 2 | FROM SERIES_MATCH 3 | WHERE PROVIDER = 'YEN_PRESS' -------------------------------------------------------------------------------- /komf-app/src/main/resources/db/migration/sqlite/V9__delete_kodansha_matches.sql: -------------------------------------------------------------------------------- 1 | DELETE 2 | FROM SERIES_MATCH 3 | WHERE PROVIDER = 'KODANSHA'; 4 | -------------------------------------------------------------------------------- /komf-app/src/main/resources/description.vm: -------------------------------------------------------------------------------- 1 | #if (${series.metadata.summary} != "") 2 | ${series.metadata.summary} 3 | #end 4 | ***new #if(${books.size()} == 1)book was #{else}books were #{end}added to library ${library.name}:*** 5 | #foreach ($book in $books) 6 | **${book.name}** 7 | #end 8 | -------------------------------------------------------------------------------- /komf-app/src/main/resources/title.vm: -------------------------------------------------------------------------------- 1 | $series.name -------------------------------------------------------------------------------- /komf-client/build.gradle.kts: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalKotlinGradlePluginApi::class) 2 | 3 | import com.vanniktech.maven.publish.SonatypeHost 4 | import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi 5 | import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl 6 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget 7 | 8 | plugins { 9 | alias(libs.plugins.androidLibrary) 10 | alias(libs.plugins.kotlinMultiplatform) 11 | alias(libs.plugins.kotlinSerialization) 12 | alias(libs.plugins.mavenPublish) 13 | signing 14 | } 15 | 16 | group = "io.github.snd-r" 17 | version = libs.versions.app.version.get() 18 | 19 | kotlin { 20 | jvmToolchain(17) 21 | androidTarget { 22 | compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } 23 | publishLibraryVariants("release") 24 | } 25 | jvm { 26 | compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } 27 | } 28 | 29 | @OptIn(ExperimentalWasmDsl::class) 30 | wasmJs { 31 | moduleName = "komf-client" 32 | browser() 33 | } 34 | 35 | sourceSets { 36 | commonMain.dependencies { 37 | api(project(":komf-api-models")) 38 | implementation(libs.kotlin.logging) 39 | implementation(libs.kotlinx.datetime) 40 | implementation(libs.kotlinx.serialization.json) 41 | implementation(libs.ktor.client.core) 42 | implementation(libs.ktor.client.content.negotiation) 43 | implementation(libs.ktor.client.encoding) 44 | implementation(libs.ktor.serialization.kotlinx.json) 45 | } 46 | } 47 | 48 | } 49 | android { 50 | namespace = "snd.komf" 51 | compileSdk = 35 52 | 53 | defaultConfig { 54 | minSdk = 26 55 | } 56 | compileOptions { 57 | sourceCompatibility = JavaVersion.VERSION_17 58 | targetCompatibility = JavaVersion.VERSION_17 59 | } 60 | } 61 | 62 | mavenPublishing { 63 | publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL, automaticRelease = false) 64 | coordinates("io.github.snd-r.komf", "client", libs.versions.app.version.get()) 65 | signAllPublications() 66 | 67 | pom { 68 | name.set("Komf API client") 69 | description.set("Komf API client") 70 | url.set("https://github.com/Snd-R/komf") 71 | licenses { 72 | license { 73 | name.set("MIT License") 74 | url.set("https://github.com/Snd-R/komf/blob/master/LICENSE") 75 | distribution.set("repo") 76 | } 77 | } 78 | developers { 79 | developer { 80 | id.set("Snd-R") 81 | name.set("Snd-R") 82 | url.set("https://github.com/Snd-R") 83 | } 84 | } 85 | scm { 86 | url.set("https://github.com/Snd-R/komf") 87 | connection.set("scm:git:git://github.com/Snd-R/komf.git") 88 | developerConnection.set("scm:git:ssh://git@github.com/Snd-R/komf.git") 89 | } 90 | } 91 | } 92 | signing { 93 | useGpgCmd() 94 | } 95 | -------------------------------------------------------------------------------- /komf-client/src/commonMain/kotlin/snd/komf/client/KomfClientFactory.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.client 2 | 3 | import io.ktor.client.* 4 | import io.ktor.client.plugins.* 5 | import io.ktor.client.plugins.contentnegotiation.* 6 | import io.ktor.client.plugins.cookies.* 7 | import io.ktor.client.plugins.sse.* 8 | import io.ktor.serialization.kotlinx.json.* 9 | import kotlinx.serialization.json.Json 10 | import snd.komf.api.MediaServer 11 | 12 | class KomfClientFactory private constructor(private val builder: Builder) { 13 | 14 | fun configClient() = KomfConfigClient(ktor) 15 | fun metadataClient(mediaServer: MediaServer) = KomfMetadataClient(ktor, mediaServer) 16 | fun mediaServerClient(mediaServer: MediaServer) = KomfMediaServerClient(ktor, mediaServer) 17 | fun jobClient() = KomfJobClient(ktor = ktor, json = json) 18 | fun notificationClient() = KomfNotificationClient(ktor = ktor) 19 | 20 | private val json = Json(builder.json) { 21 | ignoreUnknownKeys = true 22 | encodeDefaults = false 23 | } 24 | private val baseUrl: () -> String = builder.baseUrl 25 | 26 | private val ktor: HttpClient = (builder.ktor ?: HttpClient()).config { 27 | expectSuccess = true 28 | builder.cookieStorage?.let { install(HttpCookies) { storage = it } } 29 | defaultRequest { url(baseUrl()) } 30 | install(ContentNegotiation) { json(json) } 31 | install(SSE) 32 | } 33 | 34 | class Builder { 35 | internal var ktor: HttpClient? = null 36 | internal var baseUrl: () -> String = { "http://localhost:8085" } 37 | internal var cookieStorage: CookiesStorage? = AcceptAllCookiesStorage() 38 | internal var json: Json = Json 39 | 40 | fun ktor(ktor: HttpClient) = apply { 41 | this.ktor = ktor 42 | } 43 | 44 | fun baseUrl(block: () -> String) = apply { 45 | this.baseUrl = block 46 | } 47 | 48 | fun cookieStorage(cookiesStorage: CookiesStorage?) = apply { 49 | this.cookieStorage = cookiesStorage 50 | } 51 | 52 | fun build(): KomfClientFactory { 53 | return KomfClientFactory(this) 54 | } 55 | } 56 | } -------------------------------------------------------------------------------- /komf-client/src/commonMain/kotlin/snd/komf/client/KomfConfigClient.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.client 2 | 3 | import io.ktor.client.* 4 | import io.ktor.client.call.* 5 | import io.ktor.client.request.* 6 | import io.ktor.http.* 7 | import snd.komf.api.config.KomfConfig 8 | import snd.komf.api.config.KomfConfigUpdateRequest 9 | 10 | class KomfConfigClient(private val ktor: HttpClient) { 11 | 12 | suspend fun getConfig(): KomfConfig { 13 | return ktor.get("/api/config").body() 14 | } 15 | 16 | suspend fun updateConfig(request: KomfConfigUpdateRequest) { 17 | ktor.patch("/api/config") { 18 | contentType(ContentType.Application.Json) 19 | setBody(request) 20 | } 21 | } 22 | 23 | } -------------------------------------------------------------------------------- /komf-client/src/commonMain/kotlin/snd/komf/client/KomfErrorResponse.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.client 2 | 3 | import io.ktor.client.call.* 4 | import io.ktor.client.plugins.* 5 | import io.ktor.serialization.* 6 | import kotlinx.serialization.SerializationException 7 | import snd.komf.api.KomfErrorResponse 8 | 9 | suspend fun ResponseException.toKomfErrorResponse(): KomfErrorResponse? = 10 | try { 11 | response.body() 12 | } catch (e: SerializationException) { 13 | null 14 | } catch (e: JsonConvertException) { 15 | null 16 | } 17 | -------------------------------------------------------------------------------- /komf-client/src/commonMain/kotlin/snd/komf/client/KomfJobClient.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.client 2 | 3 | import io.ktor.client.* 4 | import io.ktor.client.call.* 5 | import io.ktor.client.plugins.sse.* 6 | import io.ktor.client.request.* 7 | import kotlinx.coroutines.flow.Flow 8 | import kotlinx.coroutines.flow.map 9 | import kotlinx.serialization.json.Json 10 | import snd.komf.api.KomfPage 11 | import snd.komf.api.job.KomfMetadataJob 12 | import snd.komf.api.job.KomfMetadataJobEvent 13 | import snd.komf.api.job.KomfMetadataJobEvent.ProcessingErrorEvent 14 | import snd.komf.api.job.KomfMetadataJobEvent.ProviderBookEvent 15 | import snd.komf.api.job.KomfMetadataJobEvent.ProviderCompletedEvent 16 | import snd.komf.api.job.KomfMetadataJobEvent.ProviderErrorEvent 17 | import snd.komf.api.job.KomfMetadataJobEvent.ProviderSeriesEvent 18 | import snd.komf.api.job.KomfMetadataJobEvent.UnknownEvent 19 | import snd.komf.api.job.KomfMetadataJobId 20 | import snd.komf.api.job.KomfMetadataJobStatus 21 | import snd.komf.api.job.eventsStreamNotFoundName 22 | import snd.komf.api.job.postProcessingStartName 23 | import snd.komf.api.job.processingErrorEvent 24 | import snd.komf.api.job.providerBookEventName 25 | import snd.komf.api.job.providerCompletedEventName 26 | import snd.komf.api.job.providerErrorEventName 27 | import snd.komf.api.job.providerSeriesEventName 28 | 29 | class KomfJobClient( 30 | private val ktor: HttpClient, 31 | private val json: Json 32 | ) { 33 | 34 | suspend fun getJob(jobId: KomfMetadataJobId): KomfMetadataJob { 35 | return ktor.get("/api/jobs/$jobId").body() 36 | } 37 | 38 | suspend fun getJobs( 39 | status: KomfMetadataJobStatus? = null, 40 | page: Int? = null, 41 | pageSize: Int? = null, 42 | ): KomfPage> { 43 | return ktor.get("/api/jobs") { 44 | status?.let { parameter("status", status.name) } 45 | page?.let { parameter("page", page) } 46 | pageSize?.let { parameter("pageSize", pageSize) } 47 | }.body() 48 | } 49 | 50 | suspend fun getJobEvents(jobId: KomfMetadataJobId): Flow { 51 | return ktor.sseSession("/api/jobs/${jobId.value}/events").incoming 52 | .map { json.toKomfEvent(it.event, it.data) } 53 | } 54 | 55 | suspend fun deleteAll() { 56 | ktor.delete("/api/jobs/all") 57 | } 58 | 59 | 60 | private fun Json.toKomfEvent(event: String?, data: String?): KomfMetadataJobEvent { 61 | if (data == null) return UnknownEvent 62 | 63 | return when (event) { 64 | providerSeriesEventName -> decodeFromString(data) 65 | providerBookEventName -> decodeFromString(data) 66 | providerErrorEventName -> decodeFromString(data) 67 | providerCompletedEventName -> decodeFromString(data) 68 | postProcessingStartName -> KomfMetadataJobEvent.PostProcessingStartEvent 69 | processingErrorEvent -> decodeFromString(data) 70 | eventsStreamNotFoundName -> KomfMetadataJobEvent.NotFound 71 | else -> UnknownEvent 72 | } 73 | } 74 | } -------------------------------------------------------------------------------- /komf-client/src/commonMain/kotlin/snd/komf/client/KomfMediaServerClient.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.client 2 | 3 | import io.ktor.client.* 4 | import io.ktor.client.call.* 5 | import io.ktor.client.request.* 6 | import snd.komf.api.MediaServer 7 | import snd.komf.api.mediaserver.KomfMediaServerConnectionResponse 8 | import snd.komf.api.mediaserver.KomfMediaServerLibrary 9 | 10 | class KomfMediaServerClient( 11 | private val ktor: HttpClient, 12 | mediaServer: MediaServer 13 | ) { 14 | private val mediaServerApiPrefix = "/api/${mediaServer.name.lowercase()}/media-server" 15 | 16 | suspend fun checkConnection(): KomfMediaServerConnectionResponse { 17 | return ktor.get("$mediaServerApiPrefix/connected").body() 18 | } 19 | 20 | suspend fun getLibraries(): List { 21 | return ktor.get("$mediaServerApiPrefix/libraries").body() 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /komf-client/src/commonMain/kotlin/snd/komf/client/KomfNotificationClient.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.client 2 | 3 | import io.ktor.client.* 4 | import io.ktor.client.call.* 5 | import io.ktor.client.request.* 6 | import io.ktor.http.* 7 | import snd.komf.api.notifications.KomfAppriseRenderResult 8 | import snd.komf.api.notifications.KomfAppriseRequest 9 | import snd.komf.api.notifications.KomfAppriseTemplates 10 | import snd.komf.api.notifications.KomfDiscordTemplates 11 | import snd.komf.api.notifications.KomfDiscordRenderResult 12 | import snd.komf.api.notifications.KomfDiscordRequest 13 | 14 | class KomfNotificationClient( 15 | private val ktor: HttpClient, 16 | ) { 17 | suspend fun getDiscordTemplates(): KomfDiscordTemplates { 18 | return ktor.get("/api/notifications/discord/templates").body() 19 | } 20 | 21 | suspend fun updateDiscordTemplates(templates: KomfDiscordTemplates): KomfDiscordTemplates { 22 | return ktor.post("/api/notifications/discord/templates") { 23 | contentType(ContentType.Application.Json) 24 | setBody(templates) 25 | }.body() 26 | } 27 | 28 | suspend fun renderDiscord(request: KomfDiscordRequest): KomfDiscordRenderResult { 29 | return ktor.post("/api/notifications/discord/render") { 30 | contentType(ContentType.Application.Json) 31 | setBody(request) 32 | }.body() 33 | } 34 | 35 | suspend fun sendDiscord(request: KomfDiscordRequest) { 36 | ktor.post("/api/notifications/discord/send") { 37 | contentType(ContentType.Application.Json) 38 | setBody(request) 39 | } 40 | } 41 | 42 | 43 | suspend fun getAppriseTemplates(): KomfAppriseTemplates { 44 | return ktor.get("/api/notifications/apprise/templates").body() 45 | } 46 | 47 | suspend fun updateAppriseTemplates(templates: KomfAppriseTemplates): KomfAppriseTemplates { 48 | return ktor.post("/api/notifications/apprise/templates") { 49 | contentType(ContentType.Application.Json) 50 | setBody(templates) 51 | }.body() 52 | } 53 | 54 | suspend fun renderApprise(request: KomfAppriseRequest): KomfAppriseRenderResult { 55 | return ktor.post("/api/notifications/apprise/render") { 56 | contentType(ContentType.Application.Json) 57 | setBody(request) 58 | }.body() 59 | } 60 | 61 | suspend fun sendApprise(request: KomfAppriseRequest) { 62 | ktor.post("/api/notifications/apprise/send") { 63 | contentType(ContentType.Application.Json) 64 | setBody(request) 65 | } 66 | } 67 | 68 | } -------------------------------------------------------------------------------- /komf-core/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi 2 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget 3 | 4 | plugins { 5 | alias(libs.plugins.androidLibrary) 6 | alias(libs.plugins.kotlinAtomicfu) 7 | alias(libs.plugins.kotlinMultiplatform) 8 | alias(libs.plugins.kotlinSerialization) 9 | } 10 | 11 | group = "io.github.snd-r" 12 | version = libs.versions.app.version.get() 13 | 14 | kotlin { 15 | jvmToolchain(17) 16 | jvm { 17 | @OptIn(ExperimentalKotlinGradlePluginApi::class) 18 | compilerOptions { 19 | jvmTarget.set(JvmTarget.JVM_17) 20 | } 21 | } 22 | androidTarget { 23 | @OptIn(ExperimentalKotlinGradlePluginApi::class) 24 | compilerOptions { 25 | jvmTarget.set(JvmTarget.JVM_17) 26 | } 27 | } 28 | 29 | // @OptIn(ExperimentalWasmDsl::class) 30 | // wasmJs { 31 | // moduleName = "komf-core" 32 | // } 33 | 34 | sourceSets { 35 | commonMain.dependencies { 36 | implementation(libs.cache4k) 37 | implementation(libs.commons.compress) 38 | implementation(libs.commons.text) 39 | implementation(libs.kotlin.logging) 40 | implementation(libs.kotlinx.coroutines.core) 41 | implementation(libs.kotlinx.datetime) 42 | implementation(libs.kotlinx.serialization.json) 43 | implementation(libs.kotlinx.io.core) 44 | implementation(libs.ktor.client.core) 45 | implementation(libs.ktor.client.content.negotiation) 46 | implementation(libs.ktor.client.encoding) 47 | implementation(libs.ktor.serialization.kotlinx.json) 48 | implementation(libs.ksoup) 49 | implementation(libs.xmlutil.core) 50 | implementation(libs.xmlutil.serialization) 51 | 52 | } 53 | 54 | val jvmMain by getting 55 | jvmMain.dependencies { 56 | implementation(libs.twelvemonkeys.imageio.core) 57 | implementation(libs.twelvemonkeys.imageio.jpeg) 58 | implementation(libs.twelvemonkeys.imageio.webp) 59 | 60 | } 61 | } 62 | 63 | } 64 | 65 | android { 66 | namespace = "snd.komf" 67 | compileSdk = 35 68 | 69 | defaultConfig { 70 | minSdk = 26 71 | } 72 | compileOptions { 73 | sourceCompatibility = JavaVersion.VERSION_17 74 | targetCompatibility = JavaVersion.VERSION_17 75 | } 76 | 77 | } 78 | -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/ktor/RateLimiterPlugin.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.ktor 2 | 3 | import io.ktor.client.plugins.api.* 4 | import kotlin.time.Duration 5 | import kotlin.time.Duration.Companion.minutes 6 | 7 | class RateLimiterPluginConfig { 8 | var eventsPerInterval: Int = 60 9 | var interval: Duration = 1.minutes 10 | var allowBurst = true 11 | var preconfigured: ThroughputLimiter? = null 12 | } 13 | 14 | val HttpRequestRateLimiter = createClientPlugin("RateLimiter", ::RateLimiterPluginConfig) { 15 | 16 | val limiter = pluginConfig.preconfigured 17 | ?: if (pluginConfig.allowBurst) { 18 | intervalLimiter(pluginConfig.eventsPerInterval, pluginConfig.interval) 19 | } else { 20 | rateLimiter(pluginConfig.eventsPerInterval, pluginConfig.interval) 21 | } 22 | onRequest { _, _ -> limiter.acquire() } 23 | } 24 | -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/ktor/UserAgent.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.ktor 2 | 3 | const val komfUserAgent = "Snd-R/komf (https://github.com/Snd-R/komf)" 4 | -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/model/Author.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.model 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class Author( 7 | val name: String, 8 | val role: AuthorRole 9 | ) 10 | enum class AuthorRole { 11 | WRITER, 12 | PENCILLER, 13 | INKER, 14 | COLORIST, 15 | LETTERER, 16 | COVER, 17 | EDITOR, 18 | TRANSLATOR 19 | } 20 | -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/model/BookMetadata.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.model 2 | 3 | import kotlinx.datetime.LocalDate 4 | import kotlinx.serialization.Serializable 5 | import kotlin.jvm.JvmInline 6 | import kotlin.math.floor 7 | 8 | @JvmInline 9 | @Serializable 10 | value class ProviderBookId(val id: String) 11 | 12 | @Serializable 13 | data class BookMetadata( 14 | val title: String? = null, 15 | val summary: String? = null, 16 | val number: BookRange? = null, 17 | val numberSort: Double? = null, 18 | val releaseDate: LocalDate? = null, 19 | val authors: List = emptyList(), 20 | val tags: Set = emptySet(), 21 | val isbn: String? = null, 22 | val links: List = emptyList(), 23 | val chapters: Collection = emptyList(), 24 | val storyArcs: List? = null, 25 | 26 | val startChapter: Int? = null, 27 | val endChapter: Int? = null, 28 | 29 | val thumbnail: Image? = null, 30 | ) 31 | 32 | @Serializable 33 | data class BookStoryArc(val name: String, val number: Int) 34 | 35 | @Serializable 36 | data class Chapter( 37 | val name: String?, 38 | val number: Int 39 | ) 40 | 41 | @Serializable 42 | data class BookRange( 43 | val start: Double, 44 | val end: Double 45 | ) { 46 | constructor(start: Double) : this(start, start) 47 | 48 | constructor(start: Int) : this(start.toDouble(), start.toDouble()) 49 | 50 | override fun toString(): String { 51 | val start = if (floor(start) == start) start.toInt() else start 52 | val end = if (floor(end) == end) end.toInt() else end 53 | return if (start == end) { 54 | start.toString() 55 | } else "$start-$end" 56 | } 57 | } 58 | 59 | @Serializable 60 | data class ProviderBookMetadata( 61 | val id: ProviderBookId? = null, 62 | val seriesId: ProviderSeriesId? = null, 63 | val metadata: BookMetadata, 64 | ) 65 | 66 | -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/model/Image.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.model 2 | 3 | import kotlinx.serialization.Serializable 4 | import kotlinx.serialization.Transient 5 | 6 | @Serializable 7 | data class Image( 8 | @Transient 9 | val bytes: ByteArray = byteArrayOf(), 10 | val mimeType: String? = null 11 | ) { 12 | 13 | override fun hashCode(): Int { 14 | return bytes.contentHashCode() 15 | } 16 | 17 | override fun equals(other: Any?): Boolean { 18 | if (this === other) return true 19 | if (other == null || this::class != other::class) return false 20 | 21 | other as Image 22 | 23 | if (!bytes.contentEquals(other.bytes)) return false 24 | if (mimeType != other.mimeType) return false 25 | 26 | return true 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/model/MatchQuery.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.model 2 | 3 | data class MatchQuery( 4 | val seriesName: String, 5 | val startYear: Int?, 6 | val bookQualifier: BookQualifier?, 7 | ) 8 | 9 | data class BookQualifier( 10 | val name: String, 11 | val number: BookRange, 12 | val cover: Image? 13 | ) -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/model/MatchType.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.model 2 | 3 | enum class MatchType { 4 | MANUAL, 5 | AUTOMATIC 6 | } -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/model/MediaType.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.model 2 | 3 | enum class MediaType { 4 | MANGA, 5 | NOVEL, 6 | COMIC, 7 | } -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/model/SeriesMetadata.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.model 2 | 3 | import kotlinx.datetime.LocalDate 4 | import kotlinx.serialization.Serializable 5 | import kotlin.jvm.JvmInline 6 | 7 | @JvmInline 8 | @Serializable 9 | value class ProviderSeriesId(val value: String) { 10 | override fun toString() = value 11 | } 12 | 13 | @Serializable 14 | data class SeriesMetadata( 15 | val status: SeriesStatus? = null, 16 | val title: SeriesTitle? = null, 17 | val titles: Collection = emptyList(), 18 | val summary: String? = null, 19 | val publisher: Publisher? = null, 20 | val alternativePublishers: Set = emptySet(), 21 | val readingDirection: ReadingDirection? = null, 22 | val ageRating: Int? = null, 23 | val language: String? = null, 24 | val genres: Collection = emptyList(), 25 | val tags: Collection = emptyList(), 26 | val totalBookCount: Int? = null, 27 | val authors: List = emptyList(), 28 | val releaseDate: ReleaseDate? = null, 29 | val links: Collection = emptyList(), 30 | val score: Double? = null, 31 | 32 | val thumbnail: Image? = null, 33 | ) 34 | 35 | @Serializable 36 | data class SeriesTitle( 37 | val name: String, 38 | val type: TitleType?, 39 | val language: String?, 40 | ) 41 | 42 | @Serializable 43 | data class ProviderSeriesMetadata( 44 | val id: ProviderSeriesId, 45 | val metadata: SeriesMetadata, 46 | val books: List = emptyList(), 47 | ) 48 | 49 | @Serializable 50 | data class SeriesBook( 51 | val id: ProviderBookId, 52 | val number: BookRange?, 53 | val name: String?, 54 | val type: String?, 55 | val edition: String? 56 | ) 57 | 58 | @Serializable 59 | data class ReleaseDate( 60 | val year: Int?, 61 | val month: Int?, 62 | val day: Int? 63 | ) 64 | 65 | enum class TitleType(val label: String) { 66 | ROMAJI("Romaji"), 67 | LOCALIZED("Localized"), 68 | NATIVE("Native"), 69 | } 70 | 71 | enum class ReadingDirection { 72 | LEFT_TO_RIGHT, RIGHT_TO_LEFT, VERTICAL, WEBTOON 73 | } 74 | 75 | enum class SeriesStatus { 76 | ENDED, 77 | ONGOING, 78 | ABANDONED, 79 | HIATUS, 80 | COMPLETED 81 | } 82 | 83 | @Serializable 84 | data class Publisher( 85 | val name: String, 86 | val type: PublisherType? = null, 87 | val languageTag: String? = null 88 | ) 89 | 90 | enum class PublisherType { 91 | ORIGINAL, 92 | LOCALIZED 93 | } 94 | 95 | fun LocalDate.toReleaseDate() = ReleaseDate(year = year, month = monthNumber, day = dayOfMonth) 96 | 97 | -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/model/SeriesSearchResult.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.model 2 | 3 | import kotlinx.serialization.Serializable 4 | import snd.komf.providers.CoreProviders 5 | 6 | @Serializable 7 | data class SeriesSearchResult( 8 | val url: String?, 9 | val imageUrl: String? = null, 10 | val title: String, 11 | val provider: CoreProviders, 12 | val resultId: String, 13 | ) 14 | -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/model/UpdateMode.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.model 2 | 3 | enum class UpdateMode { 4 | API, 5 | COMIC_INFO, 6 | // OPF, 7 | } 8 | -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/model/WebLink.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.model 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class WebLink( 7 | val label: String, 8 | val url: String, 9 | ) 10 | -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/providers/CoreProviders.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.providers 2 | 3 | enum class CoreProviders { 4 | ANILIST, 5 | BANGUMI, 6 | BOOK_WALKER, 7 | COMIC_VINE, 8 | HENTAG, 9 | KODANSHA, 10 | MAL, 11 | MANGA_UPDATES, 12 | MANGADEX, 13 | MANGA_BAKA, 14 | NAUTILJON, 15 | YEN_PRESS, 16 | VIZ, 17 | } 18 | -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/providers/MetadataProvider.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.providers 2 | 3 | import snd.komf.model.Image 4 | import snd.komf.model.MatchQuery 5 | import snd.komf.model.ProviderBookId 6 | import snd.komf.model.ProviderBookMetadata 7 | import snd.komf.model.ProviderSeriesId 8 | import snd.komf.model.ProviderSeriesMetadata 9 | import snd.komf.model.SeriesSearchResult 10 | 11 | interface MetadataProvider { 12 | fun providerName(): CoreProviders 13 | 14 | suspend fun getSeriesMetadata(seriesId: ProviderSeriesId): ProviderSeriesMetadata 15 | 16 | suspend fun getSeriesCover(seriesId: ProviderSeriesId): Image? 17 | 18 | suspend fun getBookMetadata(seriesId: ProviderSeriesId, bookId: ProviderBookId): ProviderBookMetadata 19 | 20 | suspend fun searchSeries(seriesName: String, limit: Int = 5): Collection 21 | 22 | suspend fun matchSeriesMetadata(matchQuery: MatchQuery): ProviderSeriesMetadata? 23 | } 24 | 25 | -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/providers/anilist/AniListMetadataProvider.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.providers.anilist 2 | 3 | import snd.komf.model.Image 4 | import snd.komf.model.MatchQuery 5 | import snd.komf.model.MediaType 6 | import snd.komf.model.ProviderBookId 7 | import snd.komf.model.ProviderBookMetadata 8 | import snd.komf.model.ProviderSeriesId 9 | import snd.komf.model.ProviderSeriesMetadata 10 | import snd.komf.model.SeriesSearchResult 11 | import snd.komf.providers.MetadataProvider 12 | import snd.komf.providers.CoreProviders.ANILIST 13 | import snd.komf.providers.anilist.model.AniListMediaFormat 14 | import snd.komf.util.NameSimilarityMatcher 15 | 16 | private val mangaMediaFormats = listOf(AniListMediaFormat.MANGA, AniListMediaFormat.ONE_SHOT) 17 | private val novelMediaFormats = listOf(AniListMediaFormat.NOVEL) 18 | 19 | class AniListMetadataProvider( 20 | private val client: AniListClient, 21 | private val metadataMapper: AniListMetadataMapper, 22 | private val nameMatcher: NameSimilarityMatcher, 23 | private val fetchSeriesCovers: Boolean, 24 | mediaType: MediaType, 25 | ) : MetadataProvider { 26 | private val seriesFormats = when (mediaType) { 27 | MediaType.MANGA -> mangaMediaFormats 28 | MediaType.NOVEL -> novelMediaFormats 29 | MediaType.COMIC -> throw IllegalStateException("Comics media type is not supported") 30 | } 31 | 32 | override fun providerName() = ANILIST 33 | 34 | override suspend fun getSeriesMetadata(seriesId: ProviderSeriesId): ProviderSeriesMetadata { 35 | val series = client.getMedia(seriesId.value.toInt()) 36 | val thumbnail = if (fetchSeriesCovers) client.getThumbnail(series) else null 37 | return metadataMapper.toSeriesMetadata(series, thumbnail) 38 | } 39 | 40 | override suspend fun getSeriesCover(seriesId: ProviderSeriesId): Image? { 41 | val series = client.getMedia(seriesId.value.toInt()) 42 | return client.getThumbnail(series) 43 | } 44 | 45 | override suspend fun getBookMetadata(seriesId: ProviderSeriesId, bookId: ProviderBookId): ProviderBookMetadata { 46 | throw UnsupportedOperationException() 47 | } 48 | 49 | override suspend fun searchSeries(seriesName: String, limit: Int): Collection { 50 | val searchResults = client.search(seriesName.take(400), seriesFormats, limit) 51 | return searchResults.map { metadataMapper.toSearchResult(it) } 52 | } 53 | 54 | override suspend fun matchSeriesMetadata(matchQuery: MatchQuery): ProviderSeriesMetadata? { 55 | val seriesName = matchQuery.seriesName 56 | val searchResults = client.search(seriesName.take(400), seriesFormats) 57 | 58 | val match = searchResults.firstOrNull { 59 | val titles = listOfNotNull( 60 | it.title?.english, 61 | it.title?.romaji, 62 | it.title?.native 63 | ) 64 | 65 | nameMatcher.matches(seriesName, titles) 66 | } 67 | 68 | return match?.let { 69 | val thumbnail = if (fetchSeriesCovers) client.getThumbnail(it) else null 70 | metadataMapper.toSeriesMetadata(it, thumbnail) 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/providers/anilist/model/AniListMedia.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.providers.anilist.model 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class AniListMedia( 7 | val id: Int, 8 | val title: AniListTitle? = null, 9 | val type: AniListMediaFormat? = null, 10 | val format: AniListMediaFormat? = null, 11 | val status: AniListMediaStatus? = null, 12 | val description: String? = null, 13 | val chapters: Int? = null, 14 | val volumes: Int? = null, 15 | val coverImage: AniListMediaCoverImage? = null, 16 | val startDate: AniListFuzzyDate? = null, 17 | val genres: List? = null, 18 | val synonyms: List? = null, 19 | val tags: List? = null, 20 | val staff: AniListStaffConnection? = null, 21 | val meanScore: Int? = null, 22 | 23 | ) 24 | 25 | @Serializable 26 | data class AniListTitle( 27 | val romaji: String? = null, 28 | val english: String? = null, 29 | val native: String? = null, 30 | ) 31 | 32 | @Serializable 33 | enum class AniListMediaFormat { 34 | TV, 35 | TV_SHORT, 36 | MOVIE, 37 | SPECIAL, 38 | OVA, 39 | ONA, 40 | MUSIC, 41 | MANGA, 42 | NOVEL, 43 | ONE_SHOT, 44 | } 45 | 46 | @Serializable 47 | enum class AniListMediaType { 48 | MANGA, 49 | ANIME, 50 | } 51 | 52 | 53 | @Serializable 54 | enum class AniListMediaStatus { 55 | FINISHED, 56 | RELEASING, 57 | NOT_YET_RELEASED, 58 | CANCELLED, 59 | HIATUS 60 | } 61 | 62 | @Serializable 63 | data class AniListMediaCoverImage( 64 | val extraLarge: String? = null, 65 | val large: String? = null, 66 | val medium: String? = null, 67 | val color: String? = null, 68 | ) 69 | 70 | @Serializable 71 | data class AniListFuzzyDate( 72 | val year: Int? = null, 73 | val month: Int? = null, 74 | val day: Int? = null 75 | ) 76 | 77 | @Serializable 78 | data class AniListMediaTag( 79 | val name: String, 80 | val description: String? = null, 81 | val category: String? = null, 82 | val rank: Int? = null 83 | ) 84 | 85 | @Serializable 86 | data class AniListStaffConnection( 87 | val edges: List 88 | ) 89 | 90 | @Serializable 91 | data class AniListStaffEdge( 92 | val node: AniListStaff? = null, 93 | val role: String? = null, 94 | ) 95 | 96 | @Serializable 97 | data class AniListStaff( 98 | val name: AniListStaffName? = null, 99 | val languageV2: String? = null 100 | ) 101 | 102 | @Serializable 103 | data class AniListStaffName( 104 | val full: String? = null 105 | ) -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/providers/anilist/model/AniListQuery.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.providers.anilist.model 2 | 3 | import kotlinx.serialization.Serializable 4 | import snd.komf.providers.anilist.model.AniListMediaFormat 5 | 6 | @Serializable 7 | data class AniListSearchQuery( 8 | val search: String, 9 | val formats: List, 10 | val perPage: Int 11 | ) 12 | 13 | @Serializable 14 | data class AniListMediaQuery( 15 | val id: Int 16 | ) -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/providers/anilist/model/AniListResponse.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.providers.anilist.model 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class AniListResponse( 7 | val data: T 8 | ) 9 | 10 | @Serializable 11 | data class AniListMediaSearchResponse( 12 | val mediaSearch: AniListMediaSearchMedia 13 | ) 14 | 15 | @Serializable 16 | data class AniListMediaSearchMedia( 17 | val media: List 18 | ) 19 | 20 | @Serializable 21 | data class AniListMediaResponse( 22 | val media: AniListMedia 23 | ) 24 | -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/providers/bangumi/BangumiClient.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.providers.bangumi 2 | 3 | import io.ktor.client.* 4 | import io.ktor.client.call.* 5 | import io.ktor.client.request.* 6 | import io.ktor.http.* 7 | import kotlinx.serialization.json.add 8 | import kotlinx.serialization.json.buildJsonObject 9 | import kotlinx.serialization.json.put 10 | import kotlinx.serialization.json.putJsonArray 11 | import snd.komf.model.Image 12 | import snd.komf.providers.bangumi.model.BangumiSubject 13 | import snd.komf.providers.bangumi.model.SearchSubjectsResponse 14 | import snd.komf.providers.bangumi.model.SubjectRelation 15 | import snd.komf.providers.bangumi.model.SubjectType 16 | 17 | class BangumiClient( 18 | private val ktor: HttpClient, 19 | ) { 20 | private val apiV0Url = "https://api.bgm.tv/v0" 21 | 22 | suspend fun searchSeries( 23 | keyword: String, 24 | ): SearchSubjectsResponse { 25 | return ktor.post("$apiV0Url/search/subjects") { 26 | contentType(ContentType.Application.Json) 27 | setBody( 28 | buildJsonObject { 29 | put("keyword", keyword) 30 | put("filter", buildJsonObject { 31 | putJsonArray("type") { add(SubjectType.BOOK.value) } 32 | put("nsfw", true) // include NSFW content 33 | }) 34 | } 35 | ) 36 | 37 | }.body() 38 | } 39 | 40 | suspend fun getSubject(subjectId: Long): BangumiSubject { 41 | return ktor.get("$apiV0Url/subjects/$subjectId") { 42 | }.body() 43 | } 44 | 45 | suspend fun getSubjectRelations(subjectId: Long): Collection { 46 | return ktor.get("$apiV0Url/subjects/$subjectId/subjects") { 47 | }.body() 48 | } 49 | 50 | suspend fun getThumbnail(subject: BangumiSubject): Image? { 51 | return (subject.images.common ?: subject.images.medium)?.ifBlank { null }?.let { 52 | val bytes: ByteArray = ktor.get(it) { 53 | }.body() 54 | Image(bytes) 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/providers/bangumi/model/SubjectSearch.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.providers.bangumi.model 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | data class SearchSubjectsResponse( 8 | val total: Int? = null, 9 | val limit: Int? = null, 10 | val offset: Int? = null, 11 | val data: List 12 | ) 13 | 14 | @Serializable 15 | data class SubjectSearchData( 16 | val id: Long, 17 | val image: String? = null, 18 | val summary: String? = null, 19 | val name: String, 20 | 21 | @SerialName("name_cn") 22 | val nameCn: String? = null, 23 | val tags: List = emptyList(), 24 | val rating: SubjectRating? = null, 25 | val type: SubjectType? = null 26 | ) 27 | -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/providers/bookwalker/BookWalkerClient.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.providers.bookwalker 2 | 3 | import io.ktor.client.* 4 | import io.ktor.client.call.* 5 | import io.ktor.client.plugins.* 6 | import io.ktor.client.request.* 7 | import io.ktor.client.statement.* 8 | import io.ktor.http.* 9 | import snd.komf.model.Image 10 | import snd.komf.providers.bookwalker.model.BookWalkerBook 11 | import snd.komf.providers.bookwalker.model.BookWalkerBookId 12 | import snd.komf.providers.bookwalker.model.BookWalkerBookListPage 13 | import snd.komf.providers.bookwalker.model.BookWalkerCategory 14 | import snd.komf.providers.bookwalker.model.BookWalkerSearchResult 15 | import snd.komf.providers.bookwalker.model.BookWalkerSeriesId 16 | 17 | const val bookWalkerBaseUrl = "https://global.bookwalker.jp" 18 | 19 | class BookWalkerClient( 20 | private val ktor: HttpClient 21 | ) { 22 | private val parser = BookWalkerParser() 23 | 24 | suspend fun searchSeries(name: String, category: BookWalkerCategory): Collection { 25 | 26 | return try { 27 | val document = ktor.get("$bookWalkerBaseUrl/search/") { 28 | parameter("word", name) 29 | parameter("qcat", category.number) 30 | parameter("np", 0) 31 | }.bodyAsText() 32 | parser.parseSearchResults(document) 33 | } catch (e: ClientRequestException) { 34 | if (e.response.status == HttpStatusCode.NotFound) emptyList() 35 | else throw e 36 | } 37 | 38 | } 39 | 40 | suspend fun getSeriesBooks(id: BookWalkerSeriesId, page: Int): BookWalkerBookListPage { 41 | val document = ktor.get("$bookWalkerBaseUrl/series/${id.id}/") { 42 | parameter("page", page) 43 | }.bodyAsText() 44 | return parser.parseSeriesBooks(document) 45 | } 46 | 47 | suspend fun getBook(id: BookWalkerBookId): BookWalkerBook { 48 | val document = ktor.get("$bookWalkerBaseUrl/${id.id}/").bodyAsText() 49 | return parser.parseBook(document) 50 | } 51 | 52 | suspend fun getThumbnail(url: String?): Image? { 53 | return url?.let { 54 | val bytes: ByteArray = ktor.get(it).body() 55 | Image(bytes) 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/providers/bookwalker/model/BookWalkerBook.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.providers.bookwalker.model 2 | 3 | import kotlinx.datetime.LocalDate 4 | import snd.komf.model.BookRange 5 | import kotlin.jvm.JvmInline 6 | 7 | @JvmInline 8 | value class BookWalkerBookId(val id: String) 9 | 10 | data class BookWalkerBook( 11 | val id: BookWalkerBookId, 12 | val seriesId: BookWalkerSeriesId?, 13 | val name: String, 14 | val number: BookRange?, 15 | 16 | val seriesTitle: String?, 17 | val japaneseTitle: String?, 18 | val romajiTitle: String?, 19 | val artists: Collection, 20 | val authors: Collection, 21 | val publisher: String, 22 | val genres: Collection, 23 | val availableSince: LocalDate?, 24 | 25 | val synopsis: String?, 26 | val imageUrl: String?, 27 | ) 28 | 29 | -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/providers/bookwalker/model/BookWalkerBookListPage.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.providers.bookwalker.model 2 | 3 | import snd.komf.model.BookRange 4 | 5 | data class BookWalkerBookListPage( 6 | val page: Int, 7 | val totalPages: Int, 8 | val books: Collection 9 | ) 10 | 11 | data class BookWalkerSeriesBook( 12 | val id: BookWalkerBookId, 13 | val number: BookRange?, 14 | val name: String 15 | ) 16 | -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/providers/bookwalker/model/BookWalkerCategory.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.providers.bookwalker.model 2 | 3 | enum class BookWalkerCategory(val number: Int) { 4 | MANGA(2), 5 | LIGHT_NOVELS(3), 6 | AUDIO_BOOKS(401), 7 | BOOKSHELF_SKIN(101), 8 | ART_BOOK(7), 9 | INTL_MANGA(11), 10 | PRACTICAL(4), 11 | FICTION(1) 12 | } -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/providers/bookwalker/model/BookWalkerSearchResult.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.providers.bookwalker.model 2 | 3 | import kotlin.jvm.JvmInline 4 | 5 | @JvmInline 6 | value class BookWalkerSeriesId(val id: String) 7 | 8 | data class BookWalkerSearchResult( 9 | val seriesId: BookWalkerSeriesId?, 10 | val bookId: BookWalkerBookId?, 11 | val seriesName: String, 12 | val imageUrl: String?, 13 | ) 14 | 15 | -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineClient.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.providers.comicvine 2 | 3 | import io.ktor.client.* 4 | import io.ktor.client.call.* 5 | import io.ktor.client.request.* 6 | import snd.komf.model.Image 7 | import snd.komf.providers.comicvine.ComicVineClient.ComicVineTypeId.ISSUE 8 | import snd.komf.providers.comicvine.ComicVineClient.ComicVineTypeId.VOLUME 9 | import snd.komf.providers.comicvine.model.ComicVineIssue 10 | import snd.komf.providers.comicvine.model.ComicVineIssueId 11 | import snd.komf.providers.comicvine.model.ComicVineSearchResult 12 | import snd.komf.providers.comicvine.model.ComicVineStoryArc 13 | import snd.komf.providers.comicvine.model.ComicVineStoryArcId 14 | import snd.komf.providers.comicvine.model.ComicVineVolume 15 | import snd.komf.providers.comicvine.model.ComicVineVolumeId 16 | import snd.komf.providers.comicvine.model.ComicVineVolumeSearch 17 | 18 | private const val baseUrl = "https://comicvine.gamespot.com/api" 19 | 20 | class ComicVineClient( 21 | private val ktor: HttpClient, 22 | private val apiKey: String, 23 | private val rateLimiter: ComicVineRateLimiter, 24 | ) { 25 | 26 | suspend fun searchVolume(name: String): ComicVineSearchResult> { 27 | rateLimiter.searchAcquire() 28 | return ktor.get("$baseUrl/search/") { 29 | parameter("query", name) 30 | parameter("format", "json") 31 | parameter("resources", "volume") 32 | parameter("api_key", apiKey) 33 | }.body() 34 | } 35 | 36 | suspend fun getVolume(id: ComicVineVolumeId): ComicVineSearchResult { 37 | rateLimiter.volumeAcquire() 38 | return ktor.get("$baseUrl/volume/${VOLUME.id}-${id.value}/") { 39 | parameter("format", "json") 40 | parameter("api_key", apiKey) 41 | }.body() 42 | } 43 | 44 | suspend fun getIssue(id: ComicVineIssueId): ComicVineSearchResult { 45 | rateLimiter.issueAcquire() 46 | return ktor.get("$baseUrl/issue/${ISSUE.id}-${id.value}/") { 47 | parameter("format", "json") 48 | parameter("api_key", apiKey) 49 | }.body() 50 | } 51 | 52 | suspend fun getStoryArc(id: ComicVineStoryArcId): ComicVineSearchResult { 53 | rateLimiter.storyArcAcquire() 54 | return ktor.get("$baseUrl/story_arc/${ComicVineTypeId.STORY_ARC.id}-${id.value}/") { 55 | parameter("format", "json") 56 | parameter("api_key", apiKey) 57 | }.body() 58 | 59 | } 60 | 61 | suspend fun getCover(url: String): Image { 62 | rateLimiter.coverAcquire() 63 | val bytes: ByteArray = ktor.get(url).body() 64 | return Image(bytes) 65 | } 66 | 67 | private enum class ComicVineTypeId(val id: Int) { 68 | VOLUME(4050), 69 | ISSUE(4000), 70 | STORY_ARC(4045) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineRateLimiter.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.providers.comicvine 2 | 3 | import snd.komf.ktor.intervalLimiter 4 | import snd.komf.ktor.rateLimiter 5 | import kotlin.time.Duration.Companion.minutes 6 | 7 | class ComicVineRateLimiter { 8 | private val searchLimiter = LimiterInternal() 9 | private val volumeLimiter = LimiterInternal() 10 | private val issueLimiter = LimiterInternal() 11 | private val storyArcLimiter = LimiterInternal() 12 | private val coverLimiter = LimiterInternal() 13 | 14 | suspend fun searchAcquire() = searchLimiter.acquire() 15 | suspend fun volumeAcquire() = volumeLimiter.acquire() 16 | suspend fun issueAcquire() = issueLimiter.acquire() 17 | suspend fun storyArcAcquire() = storyArcLimiter.acquire() 18 | suspend fun coverAcquire() = coverLimiter.acquire() 19 | 20 | private class LimiterInternal { 21 | private val burstingLimiter = intervalLimiter(50, 60.minutes) 22 | private val regularLimiter = rateLimiter(144, 60.minutes) 23 | 24 | suspend fun acquire() { 25 | if (!burstingLimiter.tryAcquire()) { 26 | regularLimiter.acquire() 27 | } 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/model/ComicVineCredit.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.providers.comicvine.model 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | data class ComicVineCredit( 8 | val id: Int, 9 | val name: String, 10 | @SerialName("api_detail_url") 11 | val apiDetailUrl: String, 12 | @SerialName("site_detail_url") 13 | val siteDetailUrl: String, 14 | ) 15 | 16 | -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/model/ComicVineImage.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.providers.comicvine.model 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable() 7 | data class ComicVineImage( 8 | @SerialName("icon_url") 9 | val iconUrl: String? = null, 10 | @SerialName("medium_url") 11 | val mediumUrl: String? = null, 12 | @SerialName("screen_url") 13 | val screenUrl: String? = null, 14 | @SerialName("screen_large_url") 15 | val screenLargeUrl: String? = null, 16 | @SerialName("small_url") 17 | val smallUrl: String? = null, 18 | @SerialName("super_url") 19 | val superUrl: String? = null, 20 | @SerialName("thumb_url") 21 | val thumbUrl: String? = null, 22 | @SerialName("tiny_url") 23 | val tinyUrl: String? = null, 24 | @SerialName("original_url") 25 | val originalUrl: String? = null, 26 | @SerialName("image_tags") 27 | val imageTags: String? = null, 28 | ) 29 | 30 | -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/model/ComicVineIssue.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.providers.comicvine.model 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | import snd.komf.model.ProviderBookId 6 | import kotlin.jvm.JvmInline 7 | 8 | @JvmInline 9 | @Serializable 10 | value class ComicVineIssueId(val value: Int) { 11 | override fun toString() = value.toString() 12 | } 13 | 14 | fun ProviderBookId.toComicVineIssueId() = ComicVineIssueId(id.toInt()) 15 | 16 | @Serializable 17 | data class ComicVineIssue( 18 | val id: ComicVineIssueId, 19 | val name: String? = null, 20 | @SerialName("api_detail_url") 21 | val apiDetailUrl: String, 22 | @SerialName("site_detail_url") 23 | val siteDetailUrl: String, 24 | 25 | val aliases: String? = null, 26 | @SerialName("associated_images") 27 | val associatedImages: List? = null, 28 | @SerialName("character_credits") 29 | val characterCredits: List? = null, 30 | @SerialName("concept_credits") 31 | val conceptCredits: List? = null, 32 | @SerialName("cover_date") 33 | val coverDate: String? = null, 34 | @SerialName("date_added") 35 | val dateAdded: String? = null, 36 | @SerialName("date_last_updated") 37 | val dateLastUpdated: String? = null, 38 | val description: String? = null, 39 | val image: ComicVineImage? = null, 40 | @SerialName("issue_number") 41 | val issueNumber: String? = null, 42 | @SerialName("location_credits") 43 | val locationCredits: List? = null, 44 | @SerialName("object_credits") 45 | val objectCredits: List? = null, 46 | @SerialName("person_credits") 47 | val personCredits: List? = null, 48 | @SerialName("store_date") 49 | val storeDate: String? = null, 50 | @SerialName("story_arc_credits") 51 | val storyArcCredits: List? = null, 52 | 53 | @SerialName("team_credits") 54 | val teamCredits: List? = null, 55 | val volume: ComicVineVolume? = null, 56 | ) 57 | 58 | @Serializable 59 | data class ComicVineIssueSlim( 60 | val id: Int, 61 | val name: String? = null, 62 | 63 | @SerialName("api_detail_url") 64 | val apiDetailUrl: String, 65 | @SerialName("site_detail_url") 66 | val siteDetailUrl: String? = null, 67 | 68 | @SerialName("issue_number") 69 | val issueNumber: String? = null, 70 | ) 71 | 72 | @Serializable 73 | data class ComicVinePersonCredit( 74 | val id: Int, 75 | val name: String, 76 | @SerialName("api_detail_url") 77 | val apiDetailUrl: String, 78 | @SerialName("site_detail_url") 79 | val siteDetailUrl: String, 80 | val role: String, 81 | ) 82 | 83 | @Serializable 84 | data class ComicVineAltImage( 85 | val id: Int, 86 | @SerialName("original_url") 87 | val originalUrl: String? = null, 88 | val caption: String? = null, 89 | @SerialName("image_tags") 90 | val imageTags: String? = null, 91 | ) 92 | 93 | -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/model/ComicVineSearchResult.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.providers.comicvine.model 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable() 7 | class ComicVineSearchResult( 8 | val error: String, 9 | val limit: Int, 10 | val offset: Int, 11 | @SerialName("number_of_page_results") 12 | val numberOfPageResults: Int, 13 | @SerialName("number_of_total_results") 14 | val numberOfTotalResults: Int, 15 | @SerialName("status_code") 16 | val statusCode: Int, 17 | val results: T, 18 | val version: String, 19 | ) 20 | 21 | -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/model/ComicVineStoryArc.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.providers.comicvine.model 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | import kotlin.jvm.JvmInline 6 | 7 | @JvmInline 8 | @Serializable 9 | value class ComicVineStoryArcId(val value: Int) { 10 | override fun toString() = value.toString() 11 | } 12 | 13 | @Serializable 14 | data class ComicVineStoryArc( 15 | val id: ComicVineStoryArcId, 16 | val name: String, 17 | val issues: List, 18 | 19 | val aliases: String? = null, 20 | @SerialName("count_of_isssue_appearances") 21 | val countOfIssueAppearances: Int? = null, 22 | val deck: String? = null, 23 | val description: String? = null, 24 | val publisher: ComicVinePublisher? = null, 25 | ) 26 | 27 | @Serializable 28 | data class ComicVineStoryArchIssue( 29 | val id: ComicVineIssueId, 30 | val name: String? = null, 31 | @SerialName("api_detail_url") 32 | val apiDetailUrl: String, 33 | ) -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/model/ComicVineVolume.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.providers.comicvine.model 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | import snd.komf.model.ProviderSeriesId 6 | import kotlin.jvm.JvmInline 7 | 8 | @JvmInline 9 | @Serializable 10 | value class ComicVineVolumeId(val value: Int) { 11 | override fun toString() = value.toString() 12 | } 13 | 14 | fun ProviderSeriesId.toComicVineVolumeId() = ComicVineVolumeId(value.toInt()) 15 | 16 | @Serializable 17 | data class ComicVineVolume( 18 | val id: ComicVineVolumeId, 19 | val name: String, 20 | @SerialName("api_detail_url") 21 | val apiDetailUrl: String, 22 | @SerialName("site_detail_url") 23 | val siteDetailUrl: String, 24 | 25 | val aliases: String? = null, 26 | @SerialName("count_of_issues") 27 | val countOfIssues: Int? = null, 28 | val description: String? = null, 29 | val image: ComicVineImage? = null, 30 | val publisher: ComicVinePublisher? = null, 31 | @SerialName("start_year") 32 | val startYear: String? = null, 33 | @SerialName("resource_type") 34 | val resourceType: String? = null, 35 | val characters: List? = null, 36 | val locations: List? = null, 37 | val issues: List? = null, 38 | val concepts: List? = null, 39 | ) 40 | 41 | @Serializable 42 | data class ComicVineVolumeSearch( 43 | val id: Int, 44 | val name: String, 45 | @SerialName("api_detail_url") 46 | val apiDetailUrl: String, 47 | @SerialName("site_detail_url") 48 | val siteDetailUrl: String, 49 | 50 | val aliases: String? = null, 51 | @SerialName("count_of_issues") 52 | val countOfIssues: Int? = null, 53 | val description: String? = null, 54 | @SerialName("first_issue") 55 | val firstIssue: ComicVineIssueSlim? = null, 56 | @SerialName("last_issue") 57 | val lastIssue: ComicVineIssueSlim? = null, 58 | val image: ComicVineImage? = null, 59 | val publisher: ComicVinePublisher? = null, 60 | @SerialName("start_year") 61 | val startYear: String? = null, 62 | @SerialName("resource_type") 63 | val resourceType: String? = null, 64 | ) 65 | 66 | @Serializable 67 | data class ComicVineConcept( 68 | val id: Int, 69 | val name: String, 70 | @SerialName("api_detail_url") 71 | val apiDetailUrl: String, 72 | @SerialName("site_detail_url") 73 | val siteDetailUrl: String, 74 | val count: String, 75 | ) 76 | 77 | @Serializable 78 | data class ComicVinePublisher( 79 | val id: Int, 80 | val name: String, 81 | @SerialName("api_detail_url") 82 | val apiDetailUrl: String, 83 | ) 84 | -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/providers/hentag/HentagBook.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.providers.hentag 2 | 3 | import kotlinx.datetime.Instant 4 | import kotlinx.serialization.KSerializer 5 | import kotlinx.serialization.Serializable 6 | import kotlinx.serialization.descriptors.PrimitiveKind 7 | import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor 8 | import kotlinx.serialization.descriptors.SerialDescriptor 9 | import kotlinx.serialization.encoding.Decoder 10 | import kotlinx.serialization.encoding.Encoder 11 | 12 | @Serializable 13 | data class HentagBook( 14 | val title: String, 15 | val coverImageUrl: String? = null, 16 | val parodies: List? = null, 17 | val circles: List? = null, 18 | val artists: List? = null, 19 | val characters: List? = null, 20 | val maleTags: List? = null, 21 | val femaleTags: List? = null, 22 | val otherTags: List? = null, 23 | val language: String, 24 | val category: String, 25 | @Serializable(with = InstantEpochMillisSerializer::class) 26 | val createdAt: Instant, 27 | @Serializable(with = InstantEpochMillisSerializer::class) 28 | val lastModified: Instant, 29 | @Serializable(with = InstantEpochMillisSerializer::class) 30 | val publishedOn: Instant? = null, 31 | val locations: List? = null, 32 | val favorite: Boolean, 33 | ) 34 | 35 | object InstantEpochMillisSerializer : KSerializer { 36 | 37 | override val descriptor: SerialDescriptor = 38 | PrimitiveSerialDescriptor("kotlinx.datetime.Instant", PrimitiveKind.LONG) 39 | 40 | override fun deserialize(decoder: Decoder): Instant = 41 | Instant.fromEpochMilliseconds(decoder.decodeLong()) 42 | 43 | override fun serialize(encoder: Encoder, value: Instant) = 44 | encoder.encodeLong(value.toEpochMilliseconds()) 45 | } 46 | -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/providers/hentag/HentagClient.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.providers.hentag 2 | 3 | import io.ktor.client.* 4 | import io.ktor.client.call.* 5 | import io.ktor.client.request.* 6 | import io.ktor.http.* 7 | import kotlinx.serialization.json.add 8 | import kotlinx.serialization.json.buildJsonObject 9 | import kotlinx.serialization.json.put 10 | import kotlinx.serialization.json.putJsonArray 11 | import snd.komf.model.Image 12 | 13 | class HentagClient(private val ktor: HttpClient) { 14 | private val baseUrl = "https://hentag.com/api/v1" 15 | 16 | suspend fun searchByTitle( 17 | title: String, 18 | language: String? = null 19 | ): List { 20 | return ktor.post("$baseUrl/search/vault/title") { 21 | contentType(ContentType.Application.Json) 22 | setBody(buildJsonObject { 23 | put("title", title) 24 | language?.let { put("language", it) } 25 | }) 26 | }.body() 27 | } 28 | 29 | suspend fun searchByUrls( 30 | urls: List, 31 | language: String? = null 32 | ): List { 33 | return ktor.post("$baseUrl/search/vault/url") { 34 | contentType(ContentType.Application.Json) 35 | setBody(buildJsonObject { 36 | putJsonArray("urls") { urls.forEach { add(it) } } 37 | language?.let { put("language", it) } 38 | }) 39 | }.body() 40 | } 41 | 42 | suspend fun searchByIds( 43 | ids: List, 44 | language: String? = null 45 | ): List { 46 | return ktor.post("$baseUrl/search/vault/id") { 47 | contentType(ContentType.Application.Json) 48 | setBody(buildJsonObject { 49 | putJsonArray("ids") { ids.forEach { add(it) } } 50 | language?.let { put("language", it) } 51 | }) 52 | }.body() 53 | } 54 | 55 | suspend fun getCover(series: HentagBook): Image? { 56 | return series.coverImageUrl?.let { 57 | val bytes = ktor.get(it).body() 58 | Image(bytes) 59 | } 60 | } 61 | } -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/providers/kodansha/KodanshaClient.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.providers.kodansha 2 | 3 | import io.ktor.client.* 4 | import io.ktor.client.call.* 5 | import io.ktor.client.request.* 6 | import snd.komf.model.Image 7 | import snd.komf.providers.kodansha.model.KodanshaBook 8 | import snd.komf.providers.kodansha.model.KodanshaBookId 9 | import snd.komf.providers.kodansha.model.KodanshaResponse 10 | import snd.komf.providers.kodansha.model.KodanshaSearchResult 11 | import snd.komf.providers.kodansha.model.KodanshaSeries 12 | import snd.komf.providers.kodansha.model.KodanshaSeriesId 13 | 14 | class KodanshaClient(private val ktor: HttpClient) { 15 | private val apiUrl = "https://api.kodansha.us" 16 | 17 | suspend fun search(name: String): KodanshaResponse> { 18 | return ktor.get("$apiUrl/search/V3") { 19 | parameter("query", name) 20 | }.body() 21 | } 22 | 23 | suspend fun getSeries(seriesId: KodanshaSeriesId): KodanshaResponse { 24 | return ktor.get("$apiUrl/series/V2/${seriesId.id}").body() 25 | } 26 | 27 | suspend fun getAllSeriesBooks(seriesId: KodanshaSeriesId): List { 28 | return ktor.get("$apiUrl/product/forSeries/${seriesId.id}").body() 29 | } 30 | 31 | suspend fun getBook(bookId: KodanshaBookId): KodanshaResponse { 32 | return ktor.get("$apiUrl/product/${bookId.id}").body() 33 | } 34 | 35 | suspend fun getThumbnail(url: String): Image { 36 | val bytes: ByteArray = ktor.get(url).body() 37 | return Image(bytes) 38 | } 39 | } 40 | 41 | -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/providers/kodansha/model/KodanshaBook.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.providers.kodansha.model 2 | 3 | import kotlinx.datetime.LocalDateTime 4 | import kotlinx.serialization.Serializable 5 | import kotlin.jvm.JvmInline 6 | 7 | @Serializable 8 | data class KodanshaBook( 9 | val id: Int, 10 | val name: String, 11 | val volumeNumber: Int? = null, 12 | val chapterNumber: Int? = null, 13 | val description: String? = null, 14 | val readable: KodanshaBookReadable, 15 | val variants: List = emptyList(), 16 | 17 | val ageRating: String? = null, 18 | val publishDate: LocalDateTime? = null, 19 | val categoryId: Int, 20 | val category: String, 21 | val subCategoryId: Int, 22 | val subCategory: String, 23 | val thumbnails: List = emptyList(), 24 | 25 | val creators: List? = null, 26 | 27 | val readableUrl: String?, 28 | ) 29 | 30 | @JvmInline 31 | value class KodanshaBookId(val id: Int) 32 | 33 | @Serializable 34 | data class KodanshaBookVariant( 35 | val type: String, 36 | val price: Double? = null, 37 | val fullPrice: Double? = null, 38 | val isComingSoon: Boolean? = null, 39 | val isPreorder: Boolean? = null, 40 | val priceType: String? = null, 41 | val id: Int, 42 | val description: String? = null, 43 | val isOnSale: Boolean? = null, 44 | val userDefaultProductImage: Boolean? = null, 45 | val thumbnails: List, 46 | ) 47 | 48 | @Serializable 49 | data class KodanshaBookReadable( 50 | val seriesId: String, 51 | val genres: List? = null, 52 | val isbn: String? = null, 53 | val eisbn: String? = null, 54 | val pageCount: Int? = null, 55 | val coverType: String? = null, 56 | val colorType: String? = null, 57 | val printReleaseDate: LocalDateTime? = null, 58 | val digitalReleaseDate: LocalDateTime? = null, 59 | val releaseDate: LocalDateTime? = null 60 | ) 61 | -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/providers/kodansha/model/KodanshaCreator.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.providers.kodansha.model 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class KodanshaCreator( 7 | val name: String, 8 | ) 9 | -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/providers/kodansha/model/KodanshaResponse.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.providers.kodansha.model 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class KodanshaResponse( 7 | val response: T, 8 | val status: KodanshaResponseStatus 9 | ) 10 | 11 | @Serializable 12 | data class KodanshaResponseStatus( 13 | val type: String 14 | ) -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/providers/kodansha/model/KodanshaSearchResult.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.providers.kodansha.model 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class KodanshaSearchResult( 7 | val type: String, 8 | val displayType: String, 9 | val content: KodanshaSearchResultContent, 10 | ) 11 | 12 | @Serializable 13 | data class KodanshaSearchResultContent( 14 | val id: Int, 15 | val title: String, 16 | val seriesName: String? = null, 17 | val description: String? = null, 18 | val thumbnails: List = emptyList(), 19 | val readableUrl: String? = null, 20 | ) 21 | -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/providers/kodansha/model/KodanshaSeries.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.providers.kodansha.model 2 | 3 | import kotlinx.serialization.Serializable 4 | import kotlin.jvm.JvmInline 5 | 6 | @Serializable 7 | data class KodanshaSeries( 8 | val id: Int, 9 | val title: String, 10 | val genres: List? = null, 11 | val creators: List? = null, 12 | val completionStatus: String? = null, 13 | val description: String? = null, 14 | val ageRating: String? = null, 15 | val thumbnails: List? = null, 16 | val publisher: String? = null, 17 | val readableUrl: String? = null, 18 | ) 19 | 20 | @JvmInline 21 | value class KodanshaSeriesId(val id: Int) 22 | 23 | @Serializable 24 | data class KodanshaGenre( 25 | val name: String, 26 | val id: Int 27 | ) 28 | -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/providers/kodansha/model/KodanshaThumbnail.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.providers.kodansha.model 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class KodanshaThumbnail( 7 | val width: Int? = null, 8 | val height: Int? = null, 9 | val fileSize: Long? = null, 10 | val url: String, 11 | val color: String? = null, 12 | ) 13 | -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/providers/mal/MalClient.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.providers.mal 2 | 3 | import io.ktor.client.* 4 | import io.ktor.client.call.* 5 | import io.ktor.client.request.* 6 | import snd.komf.model.Image 7 | import snd.komf.providers.mal.model.MalSearchResults 8 | import snd.komf.providers.mal.model.MalSeries 9 | 10 | 11 | class MalClient(private val ktor: HttpClient) { 12 | private val baseUrl = "https://api.myanimelist.net" 13 | private val includeFields: Set = setOf( 14 | "id", 15 | "title", 16 | "main_picture", 17 | "alternative_titles", 18 | "start_date", 19 | "end_date", 20 | "synopsis", 21 | "mean", 22 | "rank", 23 | "popularity", 24 | "num_list_users", 25 | "num_scoring_users", 26 | "nsfw", 27 | "genres", 28 | "created_at", 29 | "updated_at", 30 | "media_type", 31 | "status", 32 | "num_volumes", 33 | "num_chapters", 34 | "authors{first_name,last_name}", 35 | "pictures", 36 | "background", 37 | "serialization{name}", 38 | "main_picture", 39 | "pictures" 40 | ) 41 | 42 | suspend fun searchSeries(name: String): MalSearchResults { 43 | return ktor.get("$baseUrl/v2/manga") { 44 | parameter("fields", "alternative_titles,media_type") 45 | parameter("q", name) 46 | parameter("nsfw", "true") 47 | }.body() 48 | } 49 | 50 | suspend fun getSeries(id: Int): MalSeries { 51 | return ktor.get("$baseUrl/v2/manga/$id") { 52 | parameter("fields", includeFields.joinToString()) 53 | }.body() 54 | } 55 | 56 | suspend fun getThumbnail(series: MalSeries): Image? { 57 | return series.mainPicture?.medium?.let { 58 | val bytes: ByteArray = ktor.get(it).body() 59 | Image(bytes) 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/providers/mal/model/MalSearchResults.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.providers.mal.model 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | data class MalSearchResults( 8 | val data: List, 9 | val paging: MalPaging 10 | ) 11 | 12 | @Serializable 13 | data class MalSearchNode( 14 | val node: MalSearchResult, 15 | ) 16 | 17 | @Serializable 18 | data class MalSearchResult( 19 | val id: Int, 20 | val title: String, 21 | @SerialName("alternative_titles") 22 | val alternativeTitles: MalAlternativeTiltle, 23 | @SerialName("media_type") 24 | val mediaType: MalMediaType, 25 | @SerialName("main_picture") 26 | val mainPicture: MalPicture? = null, 27 | ) 28 | 29 | @Serializable 30 | data class MalPaging( 31 | val next: String? 32 | ) 33 | -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/providers/mangabaka/MangaBakaClient.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.providers.mangabaka 2 | 3 | import io.ktor.client.* 4 | import io.ktor.client.call.* 5 | import io.ktor.client.request.* 6 | import io.ktor.http.* 7 | import snd.komf.model.Image 8 | import snd.komf.providers.mangabaka.model.MangaBakaSearchResponse 9 | import snd.komf.providers.mangabaka.model.MangaBakaSeries 10 | import snd.komf.providers.mangabaka.model.MangaBakaSeriesId 11 | import snd.komf.providers.mangabaka.model.MangaBakaType 12 | 13 | class MangaBakaClient(private val ktor: HttpClient) { 14 | private val baseUrl = "https://mangabaka.dev" 15 | 16 | suspend fun searchSeries( 17 | title: String, 18 | types: List? = null, 19 | page: Int = 1, 20 | ): MangaBakaSearchResponse { 21 | return ktor.get("${baseUrl}/api/v1/series/search") { 22 | parameter("q", title) 23 | parameter("page", page.toString()) 24 | types?.forEach { parameter("type", it.name.lowercase()) } 25 | }.body() 26 | } 27 | 28 | suspend fun getSeries(id: MangaBakaSeriesId): MangaBakaSeries { 29 | return ktor.get("${baseUrl}/api/v1/series/${id}.json").body() 30 | } 31 | 32 | suspend fun getCoverBytes(url: String): Image? { 33 | val response = ktor.get(url) 34 | return Image( 35 | response.body(), 36 | response.contentType()?.let { "${it.contentType}/${it.contentSubtype}" } 37 | ) 38 | } 39 | } -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/providers/mangabaka/model/MangaBakaSearchResponse.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.providers.mangabaka.model 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class MangaBakaSearchResponse( 7 | val status: Int, 8 | val results: List 9 | ) 10 | -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/providers/mangadex/MangaDexClient.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.providers.mangadex 2 | 3 | import io.ktor.client.* 4 | import io.ktor.client.call.* 5 | import io.ktor.client.request.* 6 | import snd.komf.model.Image 7 | import snd.komf.providers.mangadex.model.MangaDexCoverArt 8 | import snd.komf.providers.mangadex.model.MangaDexManga 9 | import snd.komf.providers.mangadex.model.MangaDexMangaId 10 | import snd.komf.providers.mangadex.model.MangaDexPagedResponse 11 | import snd.komf.providers.mangadex.model.MangaDexResponse 12 | 13 | const val filesUrl: String = "https://uploads.mangadex.org" 14 | private const val apiUrl: String = "https://api.mangadex.org" 15 | 16 | class MangaDexClient(private val ktor: HttpClient) { 17 | 18 | suspend fun searchSeries( 19 | title: String, 20 | limit: Int = 5, 21 | offset: Int = 0, 22 | ): MangaDexPagedResponse> { 23 | return ktor.get("$apiUrl/manga") { 24 | parameter("limit", limit.toString()) 25 | parameter("offset", offset.toString()) 26 | parameter("includes[]", "artist") 27 | parameter("includes[]", "author") 28 | parameter("includes[]", "cover_art") 29 | parameter("order[relevance]", "desc") 30 | parameter("contentRating[]", "safe") 31 | parameter("contentRating[]", "suggestive") 32 | parameter("contentRating[]", "erotica") 33 | parameter("contentRating[]", "pornographic") 34 | parameter("title", title) 35 | }.body() 36 | } 37 | 38 | suspend fun getSeries(mangaId: MangaDexMangaId): MangaDexManga { 39 | val response: MangaDexResponse = ktor.get("$apiUrl/manga/${mangaId.value}") { 40 | parameter("includes[]", "artist") 41 | parameter("includes[]", "author") 42 | parameter("includes[]", "cover_art") 43 | }.body() 44 | 45 | return response.data 46 | } 47 | 48 | suspend fun getSeriesCovers( 49 | mangaId: MangaDexMangaId, 50 | limit: Int = 100, 51 | offset: Int = 0 52 | ): MangaDexPagedResponse> { 53 | return ktor.get("$apiUrl/cover") { 54 | parameter("limit", limit.toString()) 55 | parameter("offset", offset.toString()) 56 | parameter("manga[]", mangaId.value) 57 | }.body() 58 | } 59 | 60 | suspend fun getCover(coverId: String): MangaDexCoverArt { 61 | val response: MangaDexPagedResponse = ktor.get("$apiUrl/cover/$coverId").body() 62 | 63 | return response.data 64 | } 65 | 66 | suspend fun getCover(mangaId: MangaDexMangaId, fileName: String): Image { 67 | val bytes = ktor.get("$filesUrl/covers/${mangaId.value}/$fileName.512.jpg") 68 | .body() 69 | return Image(bytes) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/providers/mangadex/model/MangaDexLink.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.providers.mangadex.model 2 | 3 | enum class MangaDexLink { 4 | MANGA_DEX, 5 | ANILIST, 6 | ANIME_PLANET, 7 | BOOKWALKER_JP, 8 | MANGA_UPDATES, 9 | NOVEL_UPDATES, 10 | KITSU, 11 | AMAZON, 12 | EBOOK_JAPAN, 13 | MY_ANIME_LIST, 14 | CD_JAPAN, 15 | RAW, 16 | ENGLISH_TL, 17 | } -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/providers/mangadex/model/MangaDexManga.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.providers.mangadex.model 2 | 3 | import kotlinx.datetime.Instant 4 | import kotlinx.serialization.SerialName 5 | import kotlinx.serialization.Serializable 6 | import kotlin.jvm.JvmInline 7 | 8 | @Serializable 9 | data class MangaDexManga( 10 | val id: MangaDexMangaId, 11 | val type: String, 12 | val attributes: MangaDexAttributes, 13 | val relationships: List 14 | ) { 15 | fun getCoverArt(): MangaDexCoverArt? = relationships.filterIsInstance().firstOrNull() 16 | 17 | } 18 | 19 | @JvmInline 20 | @Serializable 21 | value class MangaDexMangaId(val value: String) 22 | 23 | @Serializable 24 | data class MangaDexAttributes( 25 | val title: Map, 26 | val altTitles: List>, 27 | val description: Map, 28 | val isLocked: Boolean, 29 | val links: Map?, 30 | val originalLanguage: String, 31 | val lastVolume: String?, 32 | val lastChapter: String?, 33 | val publicationDemographic: String?, 34 | val status: String, 35 | val year: Int?, 36 | val contentRating: String, 37 | val tags: List, 38 | val state: String, 39 | val chapterNumbersResetOnNewVolume: Boolean, 40 | val createdAt: Instant, 41 | val updatedAt: Instant, 42 | val version: Int, 43 | val availableTranslatedLanguages: List? = null, 44 | val latestUploadedChapter: String?, 45 | ) 46 | 47 | @Serializable 48 | abstract class MangaDexRelationship { 49 | abstract val id: String 50 | } 51 | 52 | @Serializable 53 | data class MangaDexUnknownRelationship( 54 | override val id: String, 55 | val type: String, 56 | ) : MangaDexRelationship() 57 | 58 | @Serializable 59 | @SerialName("author") 60 | class MangaDexAuthor( 61 | override val id: String, 62 | val attributes: MangaDexAuthorAttributes 63 | ) : MangaDexRelationship() 64 | 65 | @Serializable 66 | @SerialName("artist") 67 | class MangaDexArtist( 68 | override val id: String, 69 | val attributes: MangaDexAuthorAttributes 70 | ) : MangaDexRelationship() 71 | 72 | @Serializable 73 | data class MangaDexAuthorAttributes( 74 | val name: String 75 | ) 76 | 77 | @Serializable 78 | @SerialName("cover_art") 79 | class MangaDexCoverArt( 80 | override val id: String, 81 | val attributes: MangaDexCoverArtAttributes 82 | ) : MangaDexRelationship() 83 | 84 | @Serializable 85 | data class MangaDexCoverArtAttributes( 86 | val fileName: String, 87 | val volume: String?, 88 | val locale: String?, 89 | ) 90 | 91 | @Serializable 92 | data class MangaDexTag( 93 | val id: String, 94 | val type: String, 95 | val attributes: MangaDexTagAttributes 96 | ) 97 | 98 | @Serializable 99 | data class MangaDexTagAttributes( 100 | val name: Map, 101 | val description: Map, 102 | val group: String, 103 | val version: Int, 104 | ) 105 | -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/providers/mangadex/model/MangaDexResponse.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.providers.mangadex.model 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class MangaDexResponse( 7 | val result: String, 8 | val response: String, 9 | val data: T 10 | ) 11 | @Serializable 12 | data class MangaDexPagedResponse( 13 | val result: String, 14 | val response: String, 15 | val data: T, 16 | val limit: Int, 17 | val offset: Int, 18 | val total: Int, 19 | ) 20 | -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/providers/mangaupdates/model/MangaUpdatesGenre.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.providers.mangaupdates.model 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class MangaUpdatesGenre(val genre: String) 7 | -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/providers/mangaupdates/model/MangaUpdatesImage.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.providers.mangaupdates.model 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class MangaUpdatesImage(val url: MangaUpdatesImageUrl) { 7 | 8 | @Serializable 9 | data class MangaUpdatesImageUrl(val original: String?, val thumb: String?) 10 | } 11 | -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/providers/mangaupdates/model/MangaUpdatesSearchRequest.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.providers.mangaupdates.model 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class MangaUpdatesSearchRequest( 7 | val search: String, 8 | val page: Int, 9 | val perPage: Int, 10 | val type: List 11 | ) 12 | -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/providers/mangaupdates/model/MangaUpdatesSearchResult.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.providers.mangaupdates.model 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | data class SearchResult( 8 | @SerialName("series_id") 9 | val id: Long, 10 | val title: String, 11 | val description: String?, 12 | val image: MangaUpdatesImage?, 13 | val genres: Collection?, 14 | val year: String?, 15 | val url:String, 16 | ) 17 | 18 | 19 | -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/providers/mangaupdates/model/MangaUpdatesSeries.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.providers.mangaupdates.model 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | 7 | @Serializable 8 | data class MangaUpdatesSeries( 9 | @SerialName("series_id") 10 | val id: Long, 11 | val title: String, 12 | val associated: Collection, 13 | val description: String?, 14 | val image: MangaUpdatesImage?, 15 | val year: String?, 16 | val genres: Collection, 17 | val categories: Collection, 18 | val status: String?, 19 | val authors: Collection, 20 | val publishers: Collection, 21 | val url: String, 22 | @SerialName("bayesian_rating") 23 | val bayesianRating: Double? 24 | ) 25 | 26 | @Serializable 27 | data class MangaUpdatesAssociatedName(val title: String) 28 | 29 | @Serializable 30 | data class MangaUpdatesCategory( 31 | @SerialName("series_id") 32 | val id: Long, 33 | val category: String, 34 | val votes: Int, 35 | @SerialName("votes_plus") 36 | val votesPlus: Int, 37 | @SerialName("votes_minus") 38 | val votesMinus: Int, 39 | ) 40 | 41 | @Serializable 42 | data class MangaUpdatesAuthor( 43 | @SerialName("author_id") 44 | val id: Long?, 45 | val name: String, 46 | val type: String, 47 | ) 48 | 49 | @Serializable 50 | data class MangaUpdatesPublisher( 51 | @SerialName("publisher_id") 52 | val id: Long?, 53 | @SerialName("publisher_name") 54 | val name: String, 55 | val type: String, 56 | val notes: String? 57 | ) 58 | -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/providers/mangaupdates/model/SearchResultPage.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.providers.mangaupdates.model 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | import snd.komf.providers.mangaupdates.model.SearchResult 6 | 7 | @Serializable 8 | data class SearchResultPage( 9 | @SerialName("total_hits") 10 | val totalHits: Int, 11 | val page: Int, 12 | @SerialName("per_page") 13 | val perPage: Int, 14 | val results: Collection 15 | ) 16 | 17 | @Serializable 18 | data class SearchResultHit( 19 | val record: SearchResult, 20 | @SerialName("hit_title") 21 | val hitTitle: String?, 22 | ) 23 | -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/providers/mangaupdates/model/SeriesType.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.providers.mangaupdates.model 2 | 3 | enum class SeriesType(val value: String) { 4 | ARTBOOK("Artbook"), 5 | DOUJINSHI("Doujinshi"), 6 | FILIPINO("Filipino"), 7 | INDONESIAN("Indonesian"), 8 | MANGA("Manga"), 9 | MANHWA("Manhwa"), 10 | MANHUA("Manhua"), 11 | OEL("OEL"), 12 | THAI("Thai"), 13 | VIETNAMESE("Vietnamese"), 14 | MALAYSIAN("Malaysian"), 15 | NORDIC("Nordic"), 16 | FRENCH("French"), 17 | SPANISH("Spanish"), 18 | NOVEL("Novel") 19 | } -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/providers/nautiljon/NautiljonClient.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.providers.nautiljon 2 | 3 | import io.ktor.client.* 4 | import io.ktor.client.call.* 5 | import io.ktor.client.request.* 6 | import io.ktor.client.statement.* 7 | import snd.komf.model.Image 8 | import snd.komf.providers.nautiljon.model.SearchResult 9 | import snd.komf.providers.nautiljon.model.NautiljonSeries 10 | import snd.komf.providers.nautiljon.model.NautiljonSeriesId 11 | import snd.komf.providers.nautiljon.model.NautiljonVolume 12 | import snd.komf.providers.nautiljon.model.NautiljonVolumeId 13 | 14 | const val nautiljonBaseUrl = "https://www.nautiljon.com" 15 | 16 | class NautiljonClient( 17 | private val ktor: HttpClient, 18 | ) { 19 | private val parser = NautiljonParser() 20 | 21 | suspend fun searchSeries(name: String): Collection { 22 | val document = ktor.get("$nautiljonBaseUrl/mangas") { parameter("q", name) }.bodyAsText() 23 | return parser.parseSearchResults(document) 24 | } 25 | 26 | suspend fun getSeries(seriesId: NautiljonSeriesId): NautiljonSeries { 27 | val document = ktor.get("$nautiljonBaseUrl/mangas/${seriesId.value}.html").bodyAsText() 28 | return parser.parseSeries(document) 29 | } 30 | 31 | suspend fun getBook(seriesId: NautiljonSeriesId, bookId: NautiljonVolumeId): NautiljonVolume { 32 | val document = ktor.get("$nautiljonBaseUrl/mangas/${seriesId.value}/volume-${bookId.value}.html").bodyAsText() 33 | return parser.parseVolume(document) 34 | } 35 | 36 | suspend fun getSeriesThumbnail(series: NautiljonSeries): Image? { 37 | val url = series.imageUrl ?: return null 38 | val bytes: ByteArray = ktor.get(url).body() 39 | return Image(bytes) 40 | } 41 | 42 | suspend fun getVolumeThumbnail(volume: NautiljonVolume): Image? { 43 | val url = volume.imageUrl ?: return null 44 | val bytes: ByteArray = ktor.get(url).body() 45 | return Image(bytes) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/providers/nautiljon/NautiljonMetadataProvider.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.providers.nautiljon 2 | 3 | import snd.komf.model.Image 4 | import snd.komf.providers.MetadataProvider 5 | import snd.komf.util.NameSimilarityMatcher 6 | import snd.komf.providers.CoreProviders 7 | import snd.komf.providers.CoreProviders.NAUTILJON 8 | import snd.komf.model.MatchQuery 9 | import snd.komf.model.ProviderBookId 10 | import snd.komf.model.ProviderBookMetadata 11 | import snd.komf.model.ProviderSeriesId 12 | import snd.komf.model.ProviderSeriesMetadata 13 | import snd.komf.model.SeriesSearchResult 14 | import snd.komf.providers.nautiljon.model.NautiljonSeriesId 15 | import snd.komf.providers.nautiljon.model.NautiljonVolumeId 16 | 17 | class NautiljonMetadataProvider( 18 | private val client: NautiljonClient, 19 | private val metadataMapper: NautiljonSeriesMetadataMapper, 20 | private val nameMatcher: NameSimilarityMatcher, 21 | private val fetchSeriesCovers: Boolean, 22 | private val fetchBookCovers: Boolean, 23 | ) : MetadataProvider { 24 | 25 | override fun providerName(): CoreProviders { 26 | return NAUTILJON 27 | } 28 | 29 | override suspend fun getSeriesMetadata(seriesId: ProviderSeriesId): ProviderSeriesMetadata { 30 | val series = client.getSeries(NautiljonSeriesId(seriesId.value)) 31 | val thumbnail = if (fetchSeriesCovers) client.getSeriesThumbnail(series) else null 32 | 33 | return metadataMapper.toSeriesMetadata(series, thumbnail) 34 | } 35 | 36 | override suspend fun getSeriesCover(seriesId: ProviderSeriesId): Image? { 37 | val series = client.getSeries(NautiljonSeriesId(seriesId.value)) 38 | return client.getSeriesThumbnail(series) 39 | } 40 | 41 | override suspend fun getBookMetadata(seriesId: ProviderSeriesId, bookId: ProviderBookId): ProviderBookMetadata { 42 | val bookMetadata = client.getBook(NautiljonSeriesId(seriesId.value), NautiljonVolumeId(bookId.id)) 43 | val thumbnail = if (fetchBookCovers) client.getVolumeThumbnail(bookMetadata) else null 44 | 45 | return metadataMapper.toBookMetadata(bookMetadata, thumbnail) 46 | } 47 | 48 | override suspend fun searchSeries(seriesName: String, limit: Int): Collection { 49 | val searchResults = client.searchSeries(seriesName.take(400)).take(limit) 50 | return searchResults.map { metadataMapper.toSeriesSearchResult(it) } 51 | } 52 | 53 | override suspend fun matchSeriesMetadata(matchQuery: MatchQuery): ProviderSeriesMetadata? { 54 | val seriesName = matchQuery.seriesName 55 | val searchResults = client.searchSeries(seriesName.take(400)) 56 | val match = searchResults 57 | .firstOrNull { nameMatcher.matches(seriesName, listOfNotNull(it.title, it.alternativeTitle)) } 58 | 59 | return match?.let { 60 | val series = client.getSeries(it.id) 61 | val thumbnail = if (fetchSeriesCovers) client.getSeriesThumbnail(series) else null 62 | metadataMapper.toSeriesMetadata(series, thumbnail) 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/providers/nautiljon/model/NautiljonSeries.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.providers.nautiljon.model 2 | 3 | import kotlin.jvm.JvmInline 4 | 5 | 6 | @JvmInline 7 | value class NautiljonSeriesId(val value: String) 8 | 9 | data class NautiljonSeries( 10 | val id: NautiljonSeriesId, 11 | val title: String, 12 | val alternativeTitles: Collection, 13 | val romajiTitle: String?, 14 | val japaneseTitle: String?, 15 | val description: String?, 16 | val imageUrl: String?, 17 | val country: String?, 18 | val type: String?, 19 | val startYear: Int?, 20 | val status: String?, 21 | val numberOfVolumes: Int?, 22 | val genres: Collection, 23 | val themes: Collection, 24 | val authorsStory: Collection, 25 | val authorsArt: Collection, 26 | val originalPublisher: String?, 27 | val frenchPublisher: String?, 28 | val recommendedAge: Int?, 29 | val score: Double?, 30 | 31 | val volumes: Collection 32 | ) 33 | 34 | data class NautiljonSeriesVolume( 35 | val id: NautiljonVolumeId, 36 | val number: Int?, 37 | val edition: String?, 38 | val type: String?, 39 | val name: String? 40 | ) 41 | -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/providers/nautiljon/model/NautiljonVolume.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.providers.nautiljon.model 2 | 3 | import kotlinx.datetime.LocalDate 4 | import kotlin.jvm.JvmInline 5 | 6 | @JvmInline 7 | value class NautiljonVolumeId(val value: String) 8 | 9 | data class NautiljonVolume( 10 | val id: NautiljonVolumeId, 11 | val seriesId: NautiljonSeriesId, 12 | val number: Int, 13 | val originalPublisher: String?, 14 | val frenchPublisher: String?, 15 | val originalReleaseDate: LocalDate?, 16 | val frenchReleaseDate: LocalDate?, 17 | val numberOfPages: Int?, 18 | val description: String?, 19 | val score: Double?, 20 | val imageUrl: String?, 21 | val chapters: Collection, 22 | val authorsStory: Collection, 23 | val authorsArt: Collection, 24 | ) 25 | 26 | data class NautiljonChapter(val name: String?, val number: Int) 27 | -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/providers/nautiljon/model/SearchResult.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.providers.nautiljon.model 2 | 3 | data class SearchResult( 4 | val id: NautiljonSeriesId, 5 | val title: String, 6 | val alternativeTitle: String?, 7 | val description: String?, 8 | val imageUrl: String?, 9 | val type: String?, 10 | val volumesNumber: Int?, 11 | val startYear: Int?, 12 | val score: Double?, 13 | ) 14 | 15 | -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/providers/viz/VizClient.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.providers.viz 2 | 3 | import io.ktor.client.* 4 | import io.ktor.client.call.* 5 | import io.ktor.client.plugins.* 6 | import io.ktor.client.request.* 7 | import io.ktor.client.statement.* 8 | import io.ktor.http.* 9 | import snd.komf.model.Image 10 | import snd.komf.providers.viz.model.VizAllBooksId 11 | import snd.komf.providers.viz.model.VizBook 12 | import snd.komf.providers.viz.model.VizBookId 13 | import snd.komf.providers.viz.model.VizBookReleaseType 14 | import snd.komf.providers.viz.model.VizSeriesBook 15 | 16 | const val vizBaseUrl = "https://www.viz.com" 17 | 18 | class VizClient( 19 | private val ktor: HttpClient 20 | ) { 21 | private val parser = VizParser() 22 | 23 | suspend fun searchSeries(name: String): Collection { 24 | val searchQuery = "$name, Vol. 1" 25 | val document = ktor.get("$vizBaseUrl/search") { 26 | parameter("search", searchQuery) 27 | parameter("category", "Manga") 28 | }.bodyAsText() 29 | 30 | return parser.parseSearchResults(document) 31 | } 32 | 33 | suspend fun getAllBooks(id: VizAllBooksId): Collection { 34 | val document = ktor.get("$vizBaseUrl/manga-books/manga/${id.id}/all").bodyAsText() 35 | return parser.parseSeriesAllBooks(document) 36 | } 37 | 38 | suspend fun getBook(bookId: VizBookId, type: VizBookReleaseType): VizBook { 39 | val document = ktor.get("$vizBaseUrl/manga-books/manga/${bookId.value}/${type.name.lowercase()}").bodyAsText() 40 | return parser.parseBook(document) 41 | } 42 | 43 | suspend fun getThumbnail(url: String): Image? { 44 | return try { 45 | val bytes: ByteArray = ktor.get(url).body() 46 | Image(bytes) 47 | } catch (e: ClientRequestException) { 48 | if (e.response.status == HttpStatusCode.Forbidden) null 49 | else throw e 50 | } 51 | 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/providers/viz/model/AgeRating.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.providers.viz.model 2 | 3 | enum class AgeRating(val age: Int) { 4 | ALL_AGES(0), 5 | TEEN(13), 6 | TEEN_PLUS(15), 7 | MATURE(18) 8 | } 9 | -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/providers/viz/model/VizAllBooksId.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.providers.viz.model 2 | 3 | import kotlin.jvm.JvmInline 4 | 5 | @JvmInline 6 | value class VizAllBooksId(val id: String) 7 | 8 | -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/providers/viz/model/VizBook.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.providers.viz.model 2 | 3 | import kotlinx.datetime.LocalDate 4 | import snd.komf.model.BookRange 5 | import kotlin.jvm.JvmInline 6 | 7 | @JvmInline 8 | value class VizBookId(val value: String) 9 | 10 | data class VizBook( 11 | val id: VizBookId, 12 | val name: String, 13 | val seriesName: String, 14 | val number: BookRange?, 15 | val publisher: String = "Viz", 16 | val releaseDate: LocalDate?, 17 | val description: String?, 18 | val coverUrl: String?, 19 | val genres: Collection, 20 | val isbn: String?, 21 | val ageRating: AgeRating?, 22 | val authorStory: String?, 23 | val authorArt: String?, 24 | 25 | val allBooksId: VizAllBooksId?, 26 | ) 27 | 28 | fun VizBook.toVizSeriesBook() = VizSeriesBook( 29 | id = id, 30 | name = name, 31 | seriesName = seriesName, 32 | number = number, 33 | imageUrl = null, 34 | final = false //TODO 35 | ) -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/providers/viz/model/VizBookReleaseType.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.providers.viz.model 2 | 3 | enum class VizBookReleaseType { 4 | DIGITAL, 5 | PAPERBACK 6 | } -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/providers/viz/model/VizSeriesBook.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.providers.viz.model 2 | 3 | import snd.komf.model.BookRange 4 | 5 | data class VizSeriesBook( 6 | val id: VizBookId, 7 | val name: String, 8 | val seriesName: String, 9 | val number: BookRange?, 10 | val imageUrl: String?, 11 | val final: Boolean = false 12 | ) 13 | 14 | -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/providers/yenpress/model/YenPressBook.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.providers.yenpress.model 2 | 3 | import kotlinx.datetime.LocalDate 4 | import snd.komf.model.BookRange 5 | import kotlin.jvm.JvmInline 6 | 7 | data class YenPressBook( 8 | val id: YenPressBookId, 9 | val name: String, 10 | val number: BookRange?, 11 | val seriesId: YenPressSeriesId, 12 | 13 | val authors: List, 14 | val description: String?, 15 | val genres: Collection, 16 | val seriesName: String?, 17 | val pageCount: Int?, 18 | val releaseDate: LocalDate?, 19 | val isbn: String?, 20 | val ageRating: String?, 21 | val imprint: String?, 22 | val imageUrl: String?, 23 | ) 24 | 25 | @JvmInline 26 | value class YenPressBookId(val value: String) 27 | 28 | @JvmInline 29 | value class YenPressSeriesId(val value: String) 30 | 31 | data class YenPressAuthor(val role: String, val name: String) 32 | 33 | data class YenPressBookShort( 34 | val id: YenPressBookId, 35 | val number: BookRange?, 36 | val name: String?, 37 | ) 38 | 39 | data class YenPressMoreBooksResponse( 40 | val nextOrd: Int?, 41 | val books: List, 42 | ) 43 | -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/providers/yenpress/model/YenPressSearchResult.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.providers.yenpress.model 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class YenPressSearchResponse( 7 | val results: List 8 | ) 9 | 10 | @Serializable 11 | data class YenPressSearchResult( 12 | val title: YenPressSearchField, 13 | val url: YenPressSearchField, 14 | val image: YenPressSearchField?, 15 | ) { 16 | val id: YenPressSeriesId 17 | get() = YenPressSeriesId(url.raw.removePrefix("/series/")) 18 | } 19 | 20 | @Serializable 21 | data class YenPressSearchField( 22 | val raw: String 23 | ) -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/providers/yenpress/model/YenPressSeriesId.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.providers.yenpress.model 2 | 3 | import kotlinx.serialization.Serializable 4 | import kotlin.jvm.JvmInline 5 | 6 | -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/util/BookNameParser.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.util 2 | 3 | import snd.komf.model.BookRange 4 | 5 | object BookNameParser { 6 | private val volumeRegexes = listOf( 7 | "(?i),?\\s\\(?volume\\s(?[0-9]+)(,?\\s?[0-9]+,)+(?\\s?[0-9]+)\\)?".toRegex(), 8 | "(?i),?\\s\\(?([vtT]|vols\\.\\s|vol\\.\\s|volume\\s)(?[0-9]+([.x#][0-9]+)?)(?-[0-9]+([.x#][0-9]+)?)?\\)?".toRegex(), 9 | ".*第(?\\d+)-?(?\\d+)?.*巻".toRegex(), 10 | ".*年(?:[0-9]+月)?(?:[0-9]+日)?(?\\d+)-?(?\\d+)?号".toRegex(), 11 | ) 12 | 13 | private val chapterRegexes = listOf( 14 | "(?i)(\\sc|\\s?ch\\.\\s|\\s?chapter\\s|\\s?ep\\.\\s)(?[0-9]+([.x#][0-9]+)?)(?-[0-9]+([.x#][0-9]+)?)?".toRegex(), 15 | ".*第(?\\d+(\\.\\d+)?)-?(?\\d+(\\.\\d+)?)?.*話".toRegex(), 16 | ) 17 | private val bookNumberRegexes = listOf( 18 | "(?i)(?:\\s|#|no\\.)(?[0-9]+[AB]?([.x#][0-9]+)?)(?-[0-9]+([.x#][0-9]+)?)?(?:\\s\\(.*\\)\\s*)*$".toRegex() 19 | ) 20 | private val extraDataRegex = "\\[(?.*?)]".toRegex() 21 | 22 | fun getVolumes(name: String): BookRange? { 23 | val matchedGroups = volumeRegexes.firstNotNullOfOrNull { it.find(name)?.groups } 24 | val startVolume = matchedGroups?.get("volumeStart")?.value 25 | ?.replace("[x#]".toRegex(), ".") 26 | ?.toDoubleOrNull() 27 | val endVolume = matchedGroups?.get("volumeEnd")?.value 28 | ?.replace("-", "") 29 | ?.replace("[x#]".toRegex(), ".") 30 | ?.toDoubleOrNull() 31 | 32 | return if (startVolume != null && endVolume != null) { 33 | BookRange(startVolume, endVolume) 34 | } else if (startVolume != null) { 35 | BookRange(startVolume, startVolume) 36 | } else null 37 | } 38 | 39 | fun getChapters(name: String) = getBookNumber(name, chapterRegexes) 40 | fun getBookNumber(name: String) = getBookNumber(name, bookNumberRegexes) 41 | 42 | private fun getBookNumber(name: String, regexes: List): BookRange? { 43 | val matchedGroups = regexes.firstNotNullOfOrNull { it.findAll(name).lastOrNull()?.groups } 44 | val startChapter = matchedGroups?.get("start")?.value 45 | ?.replace("[x#]".toRegex(), ".") 46 | ?.toDoubleOrNull() 47 | val endChapter = matchedGroups?.get("end")?.value 48 | ?.replace("-", "") 49 | ?.replace("[x#]".toRegex(), ".") 50 | ?.toDoubleOrNull() 51 | 52 | return if (startChapter != null && endChapter != null) { 53 | BookRange(startChapter, endChapter) 54 | } else if (startChapter != null) { 55 | BookRange(startChapter) 56 | } else null 57 | } 58 | 59 | fun getExtraData(name: String): List { 60 | return extraDataRegex.findAll(name).mapNotNull { it.groups["extra"]?.value }.toList() 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/util/ImageHash.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.util 2 | 3 | expect fun compareImages(image1: ByteArray, image2: ByteArray): Boolean 4 | -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/util/NameSimilarityMatcher.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.util 2 | 3 | import snd.komf.util.NameSimilarityMatcher.NameMatchingMode.CLOSEST_MATCH 4 | import snd.komf.util.NameSimilarityMatcher.NameMatchingMode.EXACT 5 | import kotlin.math.min 6 | 7 | 8 | class NameSimilarityMatcher private constructor(private val mode: NameMatchingMode) { 9 | 10 | fun matches(name: String, namesToMatch: Collection): Boolean { 11 | return namesToMatch.any { matches(name, it) } 12 | } 13 | 14 | fun matches(name: String, nameToMatch: String): Boolean { 15 | return if (mode == EXACT || name.length in 1..3) name == nameToMatch 16 | else { 17 | val distance = levenshtein(name.uppercase(), nameToMatch.uppercase()) 18 | val distanceThreshold = when (name.length) { 19 | in 4..6 -> 1 20 | in 7..9 -> 2 21 | else -> 3 22 | } 23 | return distance <= distanceThreshold 24 | } 25 | } 26 | 27 | companion object { 28 | private val EXACT_MATCHER: NameSimilarityMatcher = NameSimilarityMatcher(EXACT) 29 | private val CLOSEST_MATCH_MATCHER: NameSimilarityMatcher = NameSimilarityMatcher(CLOSEST_MATCH) 30 | 31 | fun nameSimilarityMatcher(mode: NameMatchingMode): NameSimilarityMatcher { 32 | return when (mode) { 33 | EXACT -> EXACT_MATCHER 34 | CLOSEST_MATCH -> CLOSEST_MATCH_MATCHER 35 | } 36 | } 37 | } 38 | 39 | private fun levenshtein(lhs: CharSequence, rhs: CharSequence): Int { 40 | if (lhs == rhs) { 41 | return 0 42 | } 43 | if (lhs.isEmpty()) { 44 | return rhs.length 45 | } 46 | if (rhs.isEmpty()) { 47 | return lhs.length 48 | } 49 | 50 | val lhsLength = lhs.length + 1 51 | val rhsLength = rhs.length + 1 52 | 53 | var cost = Array(lhsLength) { it } 54 | var newCost = Array(lhsLength) { 0 } 55 | 56 | for (i in 1.. Linux 14 | name?.startsWith("Win") == true -> Windows 15 | name?.startsWith("Mac OS X") == true -> MacOS 16 | else -> Unknown 17 | } 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /komf-core/src/commonMain/kotlin/snd/komf/util/StringUtils.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.util 2 | 3 | import org.apache.commons.lang3.StringUtils 4 | 5 | private val fullwidthRegex = "[\uff01-\uff5e]".toRegex() 6 | 7 | fun replaceFullwidthChars(input: String) = input.replace(fullwidthRegex) { match -> 8 | Character.toString(match.value.codePointAt(0) - 0xfee0) 9 | } 10 | 11 | fun stripAccents(input: String): String = StringUtils.stripAccents(input) 12 | -------------------------------------------------------------------------------- /komf-mediaserver/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi 2 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget 3 | 4 | plugins { 5 | alias(libs.plugins.androidLibrary) 6 | alias(libs.plugins.kotlinAtomicfu) 7 | alias(libs.plugins.kotlinMultiplatform) 8 | alias(libs.plugins.kotlinSerialization) 9 | alias(libs.plugins.sqldelight) 10 | } 11 | 12 | group = "io.github.snd-r" 13 | version = libs.versions.app.version.get() 14 | 15 | kotlin { 16 | jvmToolchain(17) 17 | jvm { 18 | @OptIn(ExperimentalKotlinGradlePluginApi::class) 19 | compilerOptions { 20 | jvmTarget.set(JvmTarget.JVM_17) 21 | } 22 | } 23 | androidTarget { 24 | @OptIn(ExperimentalKotlinGradlePluginApi::class) 25 | compilerOptions { 26 | jvmTarget.set(JvmTarget.JVM_17) 27 | } 28 | } 29 | 30 | // @OptIn(ExperimentalWasmDsl::class) 31 | // wasmJs { 32 | // moduleName = "komf-mediaserver" 33 | // } 34 | 35 | sourceSets { 36 | commonMain.dependencies { 37 | implementation(project(":komf-core")) 38 | implementation(libs.kotlin.logging) 39 | implementation(libs.kotlinx.coroutines.core) 40 | implementation(libs.kotlinx.datetime) 41 | implementation(libs.kotlinx.io.core) 42 | implementation(libs.kotlinx.serialization.json) 43 | implementation(libs.ktor.client.core) 44 | implementation(libs.ktor.client.content.negotiation) 45 | implementation(libs.ktor.client.encoding) 46 | implementation(libs.ktor.serialization.kotlinx.json) 47 | implementation(libs.xmlutil.core) 48 | implementation(libs.xmlutil.serialization) 49 | api(libs.komga.client) 50 | 51 | } 52 | androidMain.dependencies { 53 | implementation(libs.sqldelight.android.driver) 54 | } 55 | 56 | val jvmMain by getting 57 | jvmMain.dependencies { 58 | implementation(libs.jose4j) 59 | implementation(libs.signalr) 60 | implementation(libs.sqldelight.sqlite.driver) 61 | } 62 | } 63 | 64 | } 65 | 66 | sqldelight { 67 | databases { 68 | create("Database") { 69 | packageName.set("snd.komf.mediaserver.repository") 70 | } 71 | } 72 | } 73 | 74 | android { 75 | namespace = "snd.komf" 76 | compileSdk = 35 77 | 78 | defaultConfig { 79 | minSdk = 26 80 | } 81 | compileOptions { 82 | sourceCompatibility = JavaVersion.VERSION_17 83 | targetCompatibility = JavaVersion.VERSION_17 84 | } 85 | 86 | } 87 | -------------------------------------------------------------------------------- /komf-mediaserver/src/androidMain/kotlin/snd/komf/mediaserver/repository/Database.android.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.mediaserver.repository 2 | 3 | import android.content.Context 4 | import app.cash.sqldelight.db.SqlDriver 5 | import app.cash.sqldelight.driver.android.AndroidSqliteDriver 6 | 7 | actual class DriverFactory(private val context: Context) { 8 | actual fun createDriver(): SqlDriver { 9 | return AndroidSqliteDriver(Database.Schema, context, "komf_V2.db") 10 | } 11 | } -------------------------------------------------------------------------------- /komf-mediaserver/src/commonMain/kotlin/snd/komf/mediaserver/MediaServerClient.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.mediaserver 2 | 3 | import snd.komf.mediaserver.model.MediaServerBook 4 | import snd.komf.mediaserver.model.MediaServerBookId 5 | import snd.komf.mediaserver.model.MediaServerBookMetadataUpdate 6 | import snd.komf.mediaserver.model.MediaServerBookThumbnail 7 | import snd.komf.mediaserver.model.MediaServerLibrary 8 | import snd.komf.mediaserver.model.MediaServerLibraryId 9 | import snd.komf.mediaserver.model.MediaServerSeries 10 | import snd.komf.mediaserver.model.MediaServerSeriesId 11 | import snd.komf.mediaserver.model.MediaServerSeriesMetadataUpdate 12 | import snd.komf.mediaserver.model.MediaServerSeriesThumbnail 13 | import snd.komf.mediaserver.model.MediaServerThumbnailId 14 | import snd.komf.mediaserver.model.Page 15 | import snd.komf.model.Image 16 | 17 | interface MediaServerClient { 18 | suspend fun getSeries(seriesId: MediaServerSeriesId): MediaServerSeries 19 | suspend fun getSeries(libraryId: MediaServerLibraryId, pageNumber: Int): Page 20 | suspend fun getSeriesThumbnail(seriesId: MediaServerSeriesId): Image? 21 | suspend fun getSeriesThumbnails(seriesId: MediaServerSeriesId): Collection 22 | suspend fun getBook(bookId: MediaServerBookId): MediaServerBook 23 | suspend fun getBooks(seriesId: MediaServerSeriesId): Collection 24 | suspend fun getBookThumbnails(bookId: MediaServerBookId): Collection 25 | suspend fun getBookThumbnail(bookId: MediaServerBookId): Image? 26 | suspend fun getLibrary(libraryId: MediaServerLibraryId): MediaServerLibrary 27 | suspend fun getLibraries(): List 28 | 29 | suspend fun updateSeriesMetadata(seriesId: MediaServerSeriesId, metadata: MediaServerSeriesMetadataUpdate) 30 | suspend fun deleteSeriesThumbnail(seriesId: MediaServerSeriesId, thumbnailId: MediaServerThumbnailId) 31 | suspend fun updateBookMetadata(bookId: MediaServerBookId, metadata: MediaServerBookMetadataUpdate) 32 | suspend fun deleteBookThumbnail(bookId: MediaServerBookId, thumbnailId: MediaServerThumbnailId) 33 | 34 | suspend fun resetBookMetadata(bookId: MediaServerBookId, bookName: String, bookNumber: Int?) 35 | suspend fun resetSeriesMetadata(seriesId: MediaServerSeriesId, seriesName: String) 36 | 37 | suspend fun uploadSeriesThumbnail( 38 | seriesId: MediaServerSeriesId, 39 | thumbnail: Image, 40 | selected: Boolean = false, 41 | lock: Boolean = false 42 | ): MediaServerSeriesThumbnail? 43 | 44 | suspend fun uploadBookThumbnail( 45 | bookId: MediaServerBookId, 46 | thumbnail: Image, 47 | selected: Boolean = false, 48 | lock: Boolean = false 49 | ): MediaServerBookThumbnail? 50 | 51 | suspend fun refreshMetadata(libraryId: MediaServerLibraryId, seriesId: MediaServerSeriesId) 52 | 53 | } 54 | -------------------------------------------------------------------------------- /komf-mediaserver/src/commonMain/kotlin/snd/komf/mediaserver/MediaServerEventListener.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.mediaserver 2 | 3 | import snd.komf.mediaserver.model.MediaServerBookId 4 | import snd.komf.mediaserver.model.MediaServerLibraryId 5 | import snd.komf.mediaserver.model.MediaServerSeriesId 6 | 7 | interface MediaServerEventListener { 8 | suspend fun onBooksAdded(events: List) 9 | suspend fun onBooksDeleted(events: List) {} 10 | suspend fun onSeriesDeleted(events: List) {} 11 | } 12 | 13 | data class SeriesEvent( 14 | val libraryId: MediaServerLibraryId, 15 | val seriesId: MediaServerSeriesId, 16 | ) 17 | 18 | data class BookEvent( 19 | val libraryId: MediaServerLibraryId, 20 | val seriesId: MediaServerSeriesId, 21 | val bookId: MediaServerBookId, 22 | ) 23 | -------------------------------------------------------------------------------- /komf-mediaserver/src/commonMain/kotlin/snd/komf/mediaserver/MetadataServiceProvider.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.mediaserver 2 | 3 | import snd.komf.mediaserver.metadata.MetadataService 4 | import snd.komf.mediaserver.metadata.MetadataUpdater 5 | 6 | class MetadataServiceProvider( 7 | private val defaultMetadataService: MetadataService, 8 | private val libraryMetadataServices: Map, 9 | 10 | private val defaultUpdateService: MetadataUpdater, 11 | private val libraryUpdaterServices: Map 12 | ) { 13 | fun defaultMetadataService() = defaultMetadataService 14 | fun defaultUpdateService() = defaultUpdateService 15 | 16 | fun metadataServiceFor(libraryId: String) = libraryMetadataServices[libraryId] ?: defaultMetadataService 17 | fun updateServiceFor(libraryId: String) = libraryUpdaterServices[libraryId] ?: defaultUpdateService 18 | } 19 | -------------------------------------------------------------------------------- /komf-mediaserver/src/commonMain/kotlin/snd/komf/mediaserver/jobs/KomfJobTracker.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.mediaserver.jobs 2 | 3 | import kotlinx.coroutines.CoroutineScope 4 | import kotlinx.coroutines.Dispatchers 5 | import kotlinx.coroutines.Job 6 | import kotlinx.coroutines.SupervisorJob 7 | import kotlinx.coroutines.flow.SharedFlow 8 | import kotlinx.coroutines.flow.launchIn 9 | import kotlinx.coroutines.flow.onEach 10 | import kotlinx.coroutines.launch 11 | import kotlinx.datetime.Clock 12 | import snd.komf.mediaserver.model.MediaServerSeriesId 13 | import java.util.concurrent.ConcurrentHashMap 14 | import kotlin.time.Duration.Companion.days 15 | 16 | class KomfJobTracker( 17 | private val jobsRepository: KomfJobsRepository 18 | ) { 19 | private val activeJobs = ConcurrentHashMap() 20 | private val coroutineScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) 21 | 22 | init { 23 | coroutineScope.launch { 24 | jobsRepository.cancelAllRunning() 25 | val totalCount = jobsRepository.countAll() 26 | if (totalCount > 10_000) { 27 | jobsRepository.deleteAllBeforeDate(Clock.System.now().minus(30.days)) 28 | } 29 | } 30 | } 31 | 32 | fun registerMetadataJob( 33 | seriesId: MediaServerSeriesId, 34 | flow: SharedFlow, 35 | ): MetadataJobId { 36 | val job = MetadataJob(seriesId = seriesId) 37 | jobsRepository.save(job) 38 | 39 | val listenerJob = flow 40 | .onEach { event -> 41 | when (event) { 42 | is MetadataJobEvent.ProviderErrorEvent -> { 43 | val activeJob = requireNotNull(activeJobs.remove(job.id)) 44 | jobsRepository.save( 45 | activeJob.metadataJob.fail("${event.provider}\n${event.message}") 46 | ) 47 | activeJob.flowCompletionListener.cancel() 48 | } 49 | 50 | is MetadataJobEvent.ProcessingErrorEvent -> { 51 | val activeJob = requireNotNull(activeJobs.remove(job.id)) 52 | jobsRepository.save(activeJob.metadataJob.fail(event.message)) 53 | activeJob.flowCompletionListener.cancel() 54 | } 55 | 56 | MetadataJobEvent.CompletionEvent -> { 57 | val activeJob = requireNotNull(activeJobs.remove(job.id)) 58 | jobsRepository.save(activeJob.metadataJob.complete()) 59 | activeJob.flowCompletionListener.cancel() 60 | } 61 | 62 | else -> {} 63 | } 64 | }.launchIn(coroutineScope) 65 | 66 | activeJobs[job.id] = ActiveJob(job, flow, listenerJob) 67 | return job.id 68 | } 69 | 70 | fun getMetadataJobEvents(jobId: MetadataJobId): SharedFlow? { 71 | return activeJobs[jobId]?.eventFlow 72 | } 73 | 74 | private data class ActiveJob( 75 | val metadataJob: MetadataJob, 76 | val eventFlow: SharedFlow, 77 | val flowCompletionListener: Job, 78 | ) 79 | } 80 | -------------------------------------------------------------------------------- /komf-mediaserver/src/commonMain/kotlin/snd/komf/mediaserver/jobs/KomfJobsRepository.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.mediaserver.jobs 2 | 3 | import kotlinx.datetime.Instant 4 | import snd.komf.mediaserver.repository.KomfJobRecord 5 | import snd.komf.mediaserver.repository.KomfJobRecordQueries 6 | 7 | 8 | class KomfJobsRepository( 9 | private val queries: KomfJobRecordQueries, 10 | ) { 11 | 12 | fun get(id: MetadataJobId): MetadataJob? { 13 | return queries.get(id).executeAsOneOrNull()?.fromRecord() 14 | } 15 | 16 | fun countAll( 17 | status: MetadataJobStatus? = null, 18 | ): Long { 19 | return if (status == null) 20 | queries.countAll().executeAsOne() 21 | else 22 | queries.countAllWithStatus(status).executeAsOne() 23 | } 24 | 25 | fun findAll( 26 | status: MetadataJobStatus? = null, 27 | limit: Long = 1000, 28 | offset: Long = 0 29 | ): List { 30 | val jobs = if (status == null) 31 | queries.findAll(limit, offset).executeAsList() 32 | else 33 | queries.findAllWithStatus(status, limit, offset).executeAsList() 34 | 35 | return jobs.map { it.fromRecord() } 36 | } 37 | 38 | fun save(job: MetadataJob) { 39 | queries.save(job.toRecord()) 40 | } 41 | 42 | fun cancelAllRunning() { 43 | queries.cancellAllRunning() 44 | } 45 | 46 | fun deleteAllBeforeDate(instant: Instant) { 47 | queries.deleteAllBeforeDate(instant) 48 | } 49 | 50 | fun deleteAll() { 51 | queries.deleteAll() 52 | } 53 | 54 | private fun MetadataJob.toRecord() = KomfJobRecord( 55 | id = id, 56 | seriesId = seriesId, 57 | status = status, 58 | message = message, 59 | startedAt = startedAt, 60 | finishedAt = finishedAt 61 | ) 62 | 63 | private fun KomfJobRecord.fromRecord() = MetadataJob( 64 | id = id, 65 | seriesId = seriesId, 66 | status = status, 67 | message = message, 68 | startedAt = startedAt, 69 | finishedAt = finishedAt 70 | ) 71 | } -------------------------------------------------------------------------------- /komf-mediaserver/src/commonMain/kotlin/snd/komf/mediaserver/jobs/MetadataJob.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.mediaserver.jobs 2 | 3 | import kotlinx.datetime.Clock 4 | import kotlinx.datetime.Instant 5 | import snd.komf.mediaserver.model.MediaServerSeriesId 6 | import snd.komf.providers.CoreProviders 7 | import java.util.* 8 | 9 | @JvmInline 10 | value class MetadataJobId(val value: UUID) 11 | 12 | data class MetadataJob( 13 | val seriesId: MediaServerSeriesId, 14 | val id: MetadataJobId = MetadataJobId(UUID.randomUUID()), 15 | val status: MetadataJobStatus = MetadataJobStatus.RUNNING, 16 | val message: String? = null, 17 | 18 | val startedAt: Instant = Clock.System.now(), 19 | val finishedAt: Instant? = null, 20 | ) { 21 | 22 | fun complete(): MetadataJob { 23 | return copy( 24 | status = MetadataJobStatus.COMPLETED, 25 | finishedAt = Clock.System.now() 26 | ) 27 | } 28 | 29 | fun fail(message: String): MetadataJob { 30 | return copy( 31 | status = MetadataJobStatus.FAILED, 32 | message = message, 33 | finishedAt = Clock.System.now() 34 | ) 35 | } 36 | } 37 | 38 | enum class MetadataJobStatus { 39 | RUNNING, 40 | FAILED, 41 | COMPLETED 42 | } 43 | 44 | sealed interface MetadataJobEvent { 45 | 46 | data class ProviderSeriesEvent( 47 | val provider: CoreProviders, 48 | ) : MetadataJobEvent 49 | 50 | data class ProviderBookEvent( 51 | val provider: CoreProviders, 52 | val totalBooks: Int, 53 | val bookProgress: Int, 54 | ) : MetadataJobEvent 55 | 56 | data class ProviderErrorEvent( 57 | val provider: CoreProviders, 58 | val message: String 59 | ) : MetadataJobEvent 60 | 61 | data class ProviderCompletedEvent( 62 | val provider: CoreProviders, 63 | ) : MetadataJobEvent 64 | 65 | data object PostProcessingStartEvent : MetadataJobEvent 66 | 67 | data class ProcessingErrorEvent(val message: String) : MetadataJobEvent 68 | data object CompletionEvent : MetadataJobEvent 69 | } -------------------------------------------------------------------------------- /komf-mediaserver/src/commonMain/kotlin/snd/komf/mediaserver/kavita/JwtConsumer.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.mediaserver.kavita 2 | 3 | import kotlinx.datetime.Instant 4 | 5 | interface JwtConsumer { 6 | fun processToExpirationDateClaim(jwt: String): Instant 7 | } -------------------------------------------------------------------------------- /komf-mediaserver/src/commonMain/kotlin/snd/komf/mediaserver/kavita/KavitaAuthClient.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.mediaserver.kavita 2 | 3 | import io.ktor.client.* 4 | import io.ktor.client.call.* 5 | import io.ktor.client.request.* 6 | import kotlinx.serialization.Serializable 7 | 8 | class KavitaAuthClient(private val ktor: HttpClient) { 9 | 10 | suspend fun authenticate(apiKey: String): KavitaAuthenticateResponse { 11 | return ktor.post("api/plugin/authenticate") { 12 | parameter("apiKey", apiKey) 13 | parameter("pluginName", "Komf") 14 | }.body() 15 | } 16 | 17 | } 18 | 19 | @Serializable 20 | data class KavitaAuthenticateResponse( 21 | val username: String, 22 | val email: String?, 23 | val token: String, 24 | val apiKey: String, 25 | ) 26 | -------------------------------------------------------------------------------- /komf-mediaserver/src/commonMain/kotlin/snd/komf/mediaserver/kavita/KavitaResourceNotFoundException.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.mediaserver.kavita 2 | 3 | class KavitaResourceNotFoundException : RuntimeException() -------------------------------------------------------------------------------- /komf-mediaserver/src/commonMain/kotlin/snd/komf/mediaserver/kavita/KavitaTokenProvider.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.mediaserver.kavita 2 | 3 | import kotlinx.coroutines.sync.Mutex 4 | import kotlinx.coroutines.sync.withLock 5 | import kotlinx.datetime.Clock 6 | import kotlinx.datetime.Instant 7 | import kotlin.time.Duration.Companion.hours 8 | 9 | 10 | class KavitaTokenProvider( 11 | private val kavitaClient: KavitaAuthClient, 12 | private val apiKey: String, 13 | private val jwtConsumer: JwtConsumer, 14 | private val clock: Clock, 15 | ) { 16 | private var kavitaToken: KavitaAccessToken? = null 17 | private val tokenMutex = Mutex() 18 | 19 | suspend fun getToken(): String { 20 | val currentToken = kavitaToken 21 | return if (currentToken == null || isExpired(currentToken)) updateAndGetToken().value 22 | else currentToken.value 23 | } 24 | 25 | private suspend fun updateAndGetToken(): KavitaAccessToken { 26 | tokenMutex.withLock { 27 | val lockedToken = kavitaToken 28 | 29 | return if (lockedToken == null || isExpired(lockedToken)) { 30 | getFreshToken().also { kavitaToken = it } 31 | } else lockedToken 32 | } 33 | } 34 | 35 | private suspend fun getFreshToken(): KavitaAccessToken { 36 | val jwt = kavitaClient.authenticate(apiKey).token 37 | val expirationDate = jwtConsumer.processToExpirationDateClaim(jwt) 38 | return KavitaAccessToken(jwt, expirationDate) 39 | } 40 | 41 | private fun isExpired(token: KavitaAccessToken): Boolean { 42 | return clock.now() > token.expiresAt.minus(12.hours) 43 | } 44 | } 45 | 46 | data class KavitaAccessToken( 47 | val value: String, 48 | val expiresAt: Instant, 49 | ) 50 | -------------------------------------------------------------------------------- /komf-mediaserver/src/commonMain/kotlin/snd/komf/mediaserver/kavita/model/KavitaAgeRating.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.mediaserver.kavita.model 2 | 3 | import kotlinx.serialization.KSerializer 4 | import kotlinx.serialization.Serializable 5 | import kotlinx.serialization.descriptors.PrimitiveKind 6 | import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor 7 | import kotlinx.serialization.descriptors.nullable 8 | import kotlinx.serialization.encoding.Decoder 9 | import kotlinx.serialization.encoding.Encoder 10 | import snd.komf.mediaserver.kavita.model.KavitaAgeRating.* 11 | 12 | @Serializable(with = KavitaAgeRatingSerializer::class) 13 | enum class KavitaAgeRating(val id: Int, val ageRating: Int? = null) { 14 | NOT_APPLICABLE(-1), 15 | UNKNOWN(0), 16 | RATING_PENDING(1, 0), 17 | EARLY_CHILDHOOD(2, 3), 18 | EVERYONE(3, 0), 19 | G(4, 0), 20 | EVERYONE_10PLUS(5, 10), 21 | PG(6, 8), 22 | KIDS_TO_ADULTS(7, 6), 23 | TEEN(8, 13), 24 | MATURE_15PLUS(9, 15), 25 | MATURE_17PLUS(10, 17), 26 | MATURE(11, 17), 27 | R_18PLUS(12, 18), 28 | ADULTS_ONLY(13, 18), 29 | X_18PLUS(14, 18) 30 | } 31 | 32 | class KavitaAgeRatingSerializer : KSerializer { 33 | override val descriptor = PrimitiveSerialDescriptor("KavitaAgeRating", PrimitiveKind.INT).nullable 34 | override fun serialize(encoder: Encoder, value: KavitaAgeRating) = encoder.encodeInt(value.id) 35 | override fun deserialize(decoder: Decoder): KavitaAgeRating = when (decoder.decodeInt()) { 36 | -1 -> NOT_APPLICABLE 37 | 0 -> UNKNOWN 38 | 1 -> RATING_PENDING 39 | 2 -> EARLY_CHILDHOOD 40 | 3 -> EVERYONE 41 | 4 -> G 42 | 5 -> EVERYONE_10PLUS 43 | 6 -> PG 44 | 7 -> KIDS_TO_ADULTS 45 | 8 -> TEEN 46 | 9 -> MATURE_15PLUS 47 | 10 -> MATURE_17PLUS 48 | 11 -> MATURE 49 | 12 -> R_18PLUS 50 | 13 -> ADULTS_ONLY 51 | 14 -> X_18PLUS 52 | else -> error("Unsupported status code") 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /komf-mediaserver/src/commonMain/kotlin/snd/komf/mediaserver/kavita/model/KavitaAuthor.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.mediaserver.kavita.model 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | 6 | @Serializable 7 | data class KavitaAuthor( 8 | val id: Int, 9 | val name: String, 10 | ) 11 | -------------------------------------------------------------------------------- /komf-mediaserver/src/commonMain/kotlin/snd/komf/mediaserver/kavita/model/KavitaChapter.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.mediaserver.kavita.model 2 | 3 | import kotlinx.datetime.LocalDateTime 4 | import kotlinx.serialization.Serializable 5 | import snd.komf.mediaserver.model.MediaServerBookId 6 | 7 | @JvmInline 8 | @Serializable 9 | value class KavitaChapterId(val value: Int) { 10 | override fun toString() = value.toString() 11 | } 12 | 13 | fun MediaServerBookId.toKavitaChapterId() = KavitaChapterId(value.toInt()) 14 | 15 | @Serializable 16 | data class KavitaChapter( 17 | val id: KavitaChapterId, 18 | val range: String? = null, 19 | val number: String? = null, 20 | val pages: Int, 21 | val isSpecial: Boolean, 22 | val title: String, 23 | val files: Collection, 24 | val pagesRead: Int, 25 | val coverImageLocked: Boolean, 26 | val volumeId: KavitaVolumeId, 27 | val createdUtc: LocalDateTime, 28 | val count: Int, 29 | val totalCount: Int, 30 | 31 | val summary: String? = null, 32 | val genres: Collection, 33 | val tags: Collection, 34 | val ageRating: KavitaAgeRating, 35 | val language: String? = null, 36 | val webLinks: String, 37 | val isbn: String, 38 | val releaseDate: LocalDateTime, 39 | val titleName: String, 40 | val sortOrder: Double, 41 | 42 | val writers: Collection, 43 | val coverArtists: Collection, 44 | val publishers: Collection, 45 | val characters: Collection, 46 | val pencillers: Collection, 47 | val inkers: Collection, 48 | val imprints: Collection, 49 | val colorists: Collection, 50 | val letterers: Collection, 51 | val editors: Collection, 52 | val translators: Collection, 53 | val teams: Collection, 54 | val locations: Collection, 55 | 56 | val ageRatingLocked: Boolean, 57 | val genresLocked: Boolean, 58 | val tagsLocked: Boolean, 59 | val writerLocked: Boolean, 60 | val characterLocked: Boolean, 61 | val coloristLocked: Boolean, 62 | val editorLocked: Boolean, 63 | val inkerLocked: Boolean, 64 | val imprintLocked: Boolean, 65 | val lettererLocked: Boolean, 66 | val pencillerLocked: Boolean, 67 | val publisherLocked: Boolean, 68 | val translatorLocked: Boolean, 69 | val teamLocked: Boolean, 70 | val locationLocked: Boolean, 71 | val coverArtistLocked: Boolean, 72 | val languageLocked: Boolean, 73 | val summaryLocked: Boolean, 74 | // val titleNameLocked: Boolean, 75 | // val isbnLocked: Boolean, 76 | // val releaseDateLocked: Boolean, 77 | // val sortOrderLocked: Boolean, 78 | ) 79 | 80 | @Serializable 81 | data class KavitaChapterFile( 82 | val id: Int, 83 | val filePath: String, 84 | val pages: Int, 85 | val format: Int, 86 | val created: LocalDateTime 87 | ) 88 | -------------------------------------------------------------------------------- /komf-mediaserver/src/commonMain/kotlin/snd/komf/mediaserver/kavita/model/KavitaLibrary.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.mediaserver.kavita.model 2 | 3 | import kotlinx.datetime.LocalDateTime 4 | import kotlinx.serialization.Serializable 5 | import snd.komf.mediaserver.model.MediaServerLibraryId 6 | 7 | 8 | @JvmInline 9 | @Serializable 10 | value class KavitaLibraryId(val value: Int){ 11 | override fun toString() = value.toString() 12 | } 13 | 14 | fun MediaServerLibraryId.toKavitaLibraryId() = KavitaLibraryId(value.toInt()) 15 | @Serializable 16 | data class KavitaLibrary( 17 | val id: KavitaLibraryId, 18 | val name: String, 19 | val lastScanned: LocalDateTime, 20 | val type: Int, 21 | val folders: Collection 22 | ) 23 | -------------------------------------------------------------------------------- /komf-mediaserver/src/commonMain/kotlin/snd/komf/mediaserver/kavita/model/KavitaVolume.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.mediaserver.kavita.model 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @JvmInline 6 | @Serializable 7 | value class KavitaVolumeId(val value: Int) { 8 | override fun toString() = value.toString() 9 | } 10 | 11 | @Serializable 12 | data class KavitaVolume( 13 | val id: KavitaVolumeId, 14 | val minNumber: Float, 15 | val maxNumber: Float, 16 | val name: String, 17 | val pages: Int, 18 | val seriesId: KavitaSeriesId, 19 | val chapters: Collection, 20 | ) 21 | 22 | -------------------------------------------------------------------------------- /komf-mediaserver/src/commonMain/kotlin/snd/komf/mediaserver/kavita/model/events/CoverUpdateEvent.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.mediaserver.kavita.model.events 2 | 3 | data class CoverUpdateEvent( 4 | val body: Map?, 5 | val name: String?, 6 | val title: String?, 7 | val subTitle: String?, 8 | val eventType: String?, 9 | val progress: String?, 10 | ) 11 | -------------------------------------------------------------------------------- /komf-mediaserver/src/commonMain/kotlin/snd/komf/mediaserver/kavita/model/events/KavitaEvent.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.mediaserver.kavita.model.events 2 | 3 | import kotlinx.serialization.json.Json 4 | import snd.komga.client.sse.KomgaEvent 5 | 6 | sealed interface KavitaEvent { 7 | 8 | data class UnknownEvent(val event: String?, val data: String?):KavitaEvent 9 | } 10 | 11 | fun Json.toKavitaEvent(event: String?, data: String?): KavitaEvent { 12 | return when (event) { 13 | else -> KavitaEvent.UnknownEvent(event, data) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /komf-mediaserver/src/commonMain/kotlin/snd/komf/mediaserver/kavita/model/events/NotificationProgressEvent.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.mediaserver.kavita.model.events 2 | 3 | data class NotificationProgressEvent( 4 | val body: Map?, 5 | val name: String?, 6 | val title: String?, 7 | val subTitle: String?, 8 | val eventType: String?, 9 | val progress: String?, 10 | val eventTime: String? 11 | ) 12 | -------------------------------------------------------------------------------- /komf-mediaserver/src/commonMain/kotlin/snd/komf/mediaserver/kavita/model/events/SeriesRemovedEvent.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.mediaserver.kavita.model.events 2 | 3 | data class SeriesRemovedEvent( 4 | val body: Body, 5 | val name: String?, 6 | val title: String?, 7 | val subTitle: String?, 8 | val eventType: String?, 9 | val progress: String?, 10 | ) { 11 | 12 | data class Body( 13 | val seriesId: Int, 14 | val libraryId: Int, 15 | val seriesName: String, 16 | ) 17 | } -------------------------------------------------------------------------------- /komf-mediaserver/src/commonMain/kotlin/snd/komf/mediaserver/kavita/model/request/KavitaChapterMetadataUpdateRequest.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.mediaserver.kavita.model.request 2 | 3 | import kotlinx.datetime.LocalDateTime 4 | import kotlinx.serialization.Serializable 5 | import snd.komf.mediaserver.kavita.model.KavitaAgeRating 6 | import snd.komf.mediaserver.kavita.model.KavitaAuthor 7 | import snd.komf.mediaserver.kavita.model.KavitaChapterId 8 | import snd.komf.mediaserver.kavita.model.KavitaGenre 9 | import snd.komf.mediaserver.kavita.model.KavitaTag 10 | 11 | @Serializable 12 | data class KavitaChapterMetadataUpdateRequest( 13 | val id: KavitaChapterId, 14 | 15 | val summary: String? = null, 16 | val genres: Collection, 17 | val tags: Collection, 18 | val ageRating: KavitaAgeRating, 19 | val language: String? = null, 20 | val weblinks: String, 21 | val isbn: String, 22 | val releaseDate: LocalDateTime, 23 | val titleName: String, 24 | val sortOrder: Double, 25 | 26 | val writers: Collection, 27 | val coverArtists: Collection, 28 | val publishers: Collection, 29 | val characters: Collection, 30 | val pencillers: Collection, 31 | val inkers: Collection, 32 | val imprints: Collection, 33 | val colorists: Collection, 34 | val letterers: Collection, 35 | val editors: Collection, 36 | val translators: Collection, 37 | val teams: Collection, 38 | val locations: Collection, 39 | 40 | val ageRatingLocked: Boolean, 41 | val titleNameLocked: Boolean, 42 | val genresLocked: Boolean, 43 | val tagsLocked: Boolean, 44 | val writerLocked: Boolean, 45 | val characterLocked: Boolean, 46 | val coloristLocked: Boolean, 47 | val editorLocked: Boolean, 48 | val inkerLocked: Boolean, 49 | val imprintLocked: Boolean, 50 | val lettererLocked: Boolean, 51 | val pencillerLocked: Boolean, 52 | val publisherLocked: Boolean, 53 | val translatorLocked: Boolean, 54 | val teamLocked: Boolean, 55 | val locationLocked: Boolean, 56 | val coverArtistLocked: Boolean, 57 | val languageLocked: Boolean, 58 | val summaryLocked: Boolean, 59 | val isbnLocked: Boolean, 60 | val releaseDateLocked: Boolean, 61 | val sortOrderLocked: Boolean, 62 | ) -------------------------------------------------------------------------------- /komf-mediaserver/src/commonMain/kotlin/snd/komf/mediaserver/kavita/model/request/KavitaCoverUploadRequest.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.mediaserver.kavita.model.request 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class KavitaCoverUploadRequest( 7 | val id: Int, 8 | val url: String, 9 | val lockCover: Boolean, 10 | ) -------------------------------------------------------------------------------- /komf-mediaserver/src/commonMain/kotlin/snd/komf/mediaserver/kavita/model/request/KavitaSeriesMetadataUpdateRequest.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.mediaserver.kavita.model.request 2 | 3 | import kotlinx.serialization.Serializable 4 | import snd.komf.mediaserver.kavita.model.KavitaSeriesMetadata 5 | 6 | @Serializable 7 | data class KavitaSeriesMetadataUpdateRequest( 8 | val seriesMetadata: KavitaSeriesMetadata, 9 | ) -------------------------------------------------------------------------------- /komf-mediaserver/src/commonMain/kotlin/snd/komf/mediaserver/kavita/model/request/KavitaSeriesUpdateRequest.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.mediaserver.kavita.model.request 2 | 3 | import kotlinx.serialization.Serializable 4 | import snd.komf.mediaserver.kavita.model.KavitaSeriesId 5 | 6 | @Serializable 7 | data class KavitaSeriesUpdateRequest( 8 | val id: KavitaSeriesId, 9 | val name: String, 10 | val localizedName: String? = null, 11 | val sortName: String, 12 | val coverImageLocked: Boolean, 13 | val nameLocked: Boolean, 14 | val sortNameLocked: Boolean, 15 | val localizedNameLocked: Boolean 16 | ) -------------------------------------------------------------------------------- /komf-mediaserver/src/commonMain/kotlin/snd/komf/mediaserver/metadata/MetadataEventHandler.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.mediaserver.metadata 2 | 3 | import kotlinx.coroutines.CoroutineScope 4 | import kotlinx.coroutines.Dispatchers 5 | import kotlinx.coroutines.SupervisorJob 6 | import kotlinx.coroutines.async 7 | import kotlinx.coroutines.flow.takeWhile 8 | import kotlinx.coroutines.withTimeoutOrNull 9 | import snd.komf.mediaserver.BookEvent 10 | import snd.komf.mediaserver.MediaServerEventListener 11 | import snd.komf.mediaserver.MetadataServiceProvider 12 | import snd.komf.mediaserver.SeriesEvent 13 | import snd.komf.mediaserver.jobs.KomfJobTracker 14 | import snd.komf.mediaserver.jobs.MetadataJobEvent 15 | import snd.komf.mediaserver.metadata.repository.BookThumbnailsRepository 16 | import snd.komf.mediaserver.metadata.repository.SeriesMatchRepository 17 | import snd.komf.mediaserver.metadata.repository.SeriesThumbnailsRepository 18 | import snd.komf.mediaserver.model.MediaServerSeriesId 19 | import java.util.function.Predicate 20 | import kotlin.time.Duration.Companion.minutes 21 | 22 | class MetadataEventHandler( 23 | private val metadataServiceProvider: MetadataServiceProvider, 24 | private val bookThumbnailsRepository: BookThumbnailsRepository, 25 | private val seriesThumbnailsRepository: SeriesThumbnailsRepository, 26 | private val seriesMatchRepository: SeriesMatchRepository, 27 | private val jobTracker: KomfJobTracker, 28 | 29 | private val libraryFilter: Predicate, 30 | private val seriesFilter: Predicate, 31 | ) : MediaServerEventListener { 32 | private val jobScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) 33 | 34 | override suspend fun onBooksAdded(events: List) { 35 | val jobIds = events.filter { libraryFilter.test(it.libraryId.value) && seriesFilter.test(it.seriesId.value) } 36 | .groupBy { it.libraryId } 37 | .flatMap { (libraryId, events) -> 38 | events.groupBy { MediaServerSeriesId(it.seriesId.value) } 39 | .map { (seriesId, _) -> 40 | val metadataService = metadataServiceProvider.metadataServiceFor(libraryId.value) 41 | metadataService.matchSeriesMetadata(seriesId) 42 | } 43 | } 44 | jobIds.map { id -> 45 | jobScope.async { 46 | withTimeoutOrNull(10.minutes) { 47 | jobTracker.getMetadataJobEvents(id) 48 | ?.takeWhile { it !is MetadataJobEvent.CompletionEvent } 49 | ?.collect {} 50 | } 51 | } 52 | }.forEach { it.await() } 53 | } 54 | 55 | override suspend fun onBooksDeleted(events: List) { 56 | events.forEach { bookThumbnailsRepository.delete(it.bookId) } 57 | } 58 | 59 | override suspend fun onSeriesDeleted(events: List) { 60 | events.forEach { 61 | seriesThumbnailsRepository.delete(it.seriesId) 62 | seriesMatchRepository.delete(it.seriesId) 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /komf-mediaserver/src/commonMain/kotlin/snd/komf/mediaserver/metadata/repository/BookThumbnailsRepository.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.mediaserver.metadata.repository 2 | 3 | import snd.komf.mediaserver.model.MediaServer 4 | import snd.komf.mediaserver.model.MediaServerBookId 5 | import snd.komf.mediaserver.model.MediaServerSeriesId 6 | import snd.komf.mediaserver.model.MediaServerThumbnailId 7 | import snd.komf.mediaserver.repository.BookThumbnail 8 | import snd.komf.mediaserver.repository.BookThumbnailQueries 9 | 10 | class BookThumbnailsRepository( 11 | private val queries: BookThumbnailQueries, 12 | private val mediaServer: MediaServer 13 | ) { 14 | 15 | fun findFor(bookId: MediaServerBookId): BookThumbnail? { 16 | return queries.findFor(bookId).executeAsOneOrNull() 17 | } 18 | 19 | fun save( 20 | bookId: MediaServerBookId, 21 | seriesId: MediaServerSeriesId, 22 | thumbnailId: MediaServerThumbnailId?, 23 | ) { 24 | queries.save( 25 | bookId = bookId, 26 | seriesId = seriesId, 27 | thumbnailId = thumbnailId, 28 | mediaServer = mediaServer 29 | ) 30 | } 31 | 32 | fun delete(bookId: MediaServerBookId) { 33 | queries.delete(bookId) 34 | } 35 | } -------------------------------------------------------------------------------- /komf-mediaserver/src/commonMain/kotlin/snd/komf/mediaserver/metadata/repository/SeriesMatchRepository.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.mediaserver.metadata.repository 2 | 3 | import snd.komf.mediaserver.model.MediaServer 4 | import snd.komf.mediaserver.model.MediaServerSeriesId 5 | import snd.komf.mediaserver.repository.SeriesMatch 6 | import snd.komf.mediaserver.repository.SeriesMatchQueries 7 | import snd.komf.model.MatchType 8 | import snd.komf.model.ProviderSeriesId 9 | import snd.komf.providers.CoreProviders 10 | 11 | class SeriesMatchRepository( 12 | private val queries: SeriesMatchQueries, 13 | private val mediaServer: MediaServer, 14 | ) { 15 | 16 | fun findManualFor(seriesId: MediaServerSeriesId): SeriesMatch? { 17 | return queries.findManualFor( 18 | seriesId = seriesId, 19 | mediaServer = mediaServer 20 | ).executeAsOneOrNull() 21 | } 22 | 23 | fun save( 24 | seriesId: MediaServerSeriesId, 25 | type: MatchType, 26 | provider: CoreProviders, 27 | providerSeriesId: ProviderSeriesId, 28 | ) { 29 | queries.save( 30 | seriesId = seriesId, 31 | type = type, 32 | mediaServer = mediaServer, 33 | provider = provider, 34 | providerSeriesId = providerSeriesId 35 | ) 36 | } 37 | 38 | fun delete(seriesId: MediaServerSeriesId) { 39 | queries.delete(seriesId) 40 | } 41 | } -------------------------------------------------------------------------------- /komf-mediaserver/src/commonMain/kotlin/snd/komf/mediaserver/metadata/repository/SeriesThumbnailsRepository.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.mediaserver.metadata.repository 2 | 3 | import snd.komf.mediaserver.model.MediaServer 4 | import snd.komf.mediaserver.model.MediaServerSeriesId 5 | import snd.komf.mediaserver.model.MediaServerThumbnailId 6 | import snd.komf.mediaserver.repository.SeriesThumbnail 7 | import snd.komf.mediaserver.repository.SeriesThumbnailQueries 8 | 9 | class SeriesThumbnailsRepository( 10 | private val queries: SeriesThumbnailQueries, 11 | private val mediaServer: MediaServer 12 | ) { 13 | 14 | fun findFor(seriesId: MediaServerSeriesId): SeriesThumbnail? { 15 | return queries.findFor(seriesId).executeAsOneOrNull() 16 | } 17 | 18 | fun save( 19 | seriesId: MediaServerSeriesId, 20 | thumbnailId: MediaServerThumbnailId?, 21 | ) { 22 | queries.save( 23 | seriesId = seriesId, 24 | thumbnailId = thumbnailId, 25 | mediaServer = mediaServer 26 | ) 27 | } 28 | 29 | fun delete(seriesId: MediaServerSeriesId) { 30 | queries.delete(seriesId) 31 | } 32 | } -------------------------------------------------------------------------------- /komf-mediaserver/src/commonMain/kotlin/snd/komf/mediaserver/model/MediaServer.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.mediaserver.model 2 | 3 | enum class MediaServer { 4 | KOMGA, 5 | KAVITA 6 | } 7 | -------------------------------------------------------------------------------- /komf-mediaserver/src/commonMain/kotlin/snd/komf/mediaserver/model/MediaServerAlternativeTitle.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.mediaserver.model 2 | 3 | data class MediaServerAlternativeTitle( 4 | val label: String, 5 | val title: String, 6 | ) 7 | 8 | -------------------------------------------------------------------------------- /komf-mediaserver/src/commonMain/kotlin/snd/komf/mediaserver/model/MediaServerAuthor.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.mediaserver.model 2 | 3 | data class MediaServerAuthor( 4 | val name: String, 5 | val role: String, 6 | ) 7 | 8 | -------------------------------------------------------------------------------- /komf-mediaserver/src/commonMain/kotlin/snd/komf/mediaserver/model/MediaServerBook.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.mediaserver.model 2 | 3 | data class MediaServerBook( 4 | val id: MediaServerBookId, 5 | val seriesId: MediaServerSeriesId, 6 | val libraryId: MediaServerLibraryId?, 7 | val seriesTitle: String, 8 | val name: String, 9 | val url: String, 10 | val number: Int, 11 | val oneshot: Boolean, 12 | val metadata: MediaServerBookMetadata, 13 | val deleted: Boolean, 14 | ) 15 | 16 | @JvmInline 17 | value class MediaServerBookId(val value: String) { 18 | override fun toString() = value 19 | } 20 | -------------------------------------------------------------------------------- /komf-mediaserver/src/commonMain/kotlin/snd/komf/mediaserver/model/MediaServerBookMetadata.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.mediaserver.model 2 | 3 | import kotlinx.datetime.LocalDate 4 | import snd.komf.model.WebLink 5 | 6 | data class MediaServerBookMetadata( 7 | val title: String, 8 | val summary: String?, 9 | val number: String, 10 | val numberSort: String?, 11 | val releaseDate: LocalDate?, 12 | val authors: List, 13 | val tags: Collection, 14 | val isbn: String?, 15 | val links: Collection, 16 | 17 | val titleLock: Boolean, 18 | val summaryLock: Boolean, 19 | val numberLock: Boolean, 20 | val numberSortLock: Boolean, 21 | val releaseDateLock: Boolean, 22 | val authorsLock: Boolean, 23 | val tagsLock: Boolean, 24 | val isbnLock: Boolean, 25 | val linksLock: Boolean, 26 | ) 27 | -------------------------------------------------------------------------------- /komf-mediaserver/src/commonMain/kotlin/snd/komf/mediaserver/model/MediaServerBookMetadataUpdate.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.mediaserver.model 2 | 3 | import kotlinx.datetime.LocalDate 4 | import snd.komf.model.WebLink 5 | 6 | data class MediaServerBookMetadataUpdate( 7 | val title: String? = null, 8 | val summary: String? = null, 9 | val number: String? = null, 10 | val numberSort: Double? = null, 11 | val releaseDate: LocalDate? = null, 12 | val authors: List? = null, 13 | val tags: List? = null, 14 | val isbn: String? = null, 15 | val links: Collection? = null, 16 | 17 | val titleLock: Boolean? = null, 18 | val summaryLock: Boolean? = null, 19 | val numberLock: Boolean? = null, 20 | val numberSortLock: Boolean? = null, 21 | val releaseDateLock: Boolean? = null, 22 | val authorsLock: Boolean? = null, 23 | val tagsLock: Boolean? = null, 24 | val isbnLock: Boolean? = null, 25 | val linksLock: Boolean? = null, 26 | ) 27 | -------------------------------------------------------------------------------- /komf-mediaserver/src/commonMain/kotlin/snd/komf/mediaserver/model/MediaServerBookThumbnail.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.mediaserver.model 2 | 3 | data class MediaServerBookThumbnail( 4 | val id: MediaServerThumbnailId, 5 | val bookId: MediaServerBookId, 6 | val type: String?, 7 | val selected: Boolean, 8 | ) 9 | 10 | -------------------------------------------------------------------------------- /komf-mediaserver/src/commonMain/kotlin/snd/komf/mediaserver/model/MediaServerLibrary.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.mediaserver.model 2 | 3 | data class MediaServerLibrary( 4 | val id: MediaServerLibraryId, 5 | val name: String, 6 | val roots: Collection, 7 | ) 8 | @JvmInline 9 | value class MediaServerLibraryId(val value: String) 10 | -------------------------------------------------------------------------------- /komf-mediaserver/src/commonMain/kotlin/snd/komf/mediaserver/model/MediaServerSeries.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.mediaserver.model 2 | 3 | data class MediaServerSeries( 4 | val id: MediaServerSeriesId, 5 | val libraryId: MediaServerLibraryId, 6 | val name: String, 7 | val booksCount: Int, 8 | val metadata: MediaServerSeriesMetadata, 9 | val url: String, 10 | val deleted: Boolean, 11 | ) 12 | 13 | @JvmInline 14 | value class MediaServerSeriesId(val value: String) { 15 | override fun toString() = value 16 | } 17 | -------------------------------------------------------------------------------- /komf-mediaserver/src/commonMain/kotlin/snd/komf/mediaserver/model/MediaServerSeriesMetadata.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.mediaserver.model 2 | 3 | import snd.komf.model.ReadingDirection 4 | import snd.komf.model.SeriesStatus 5 | import snd.komf.model.WebLink 6 | 7 | data class MediaServerSeriesMetadata( 8 | val status: SeriesStatus, 9 | val title: String, 10 | val titleSort: String, 11 | val alternativeTitles: Collection, 12 | val summary: String, 13 | val readingDirection: ReadingDirection?, 14 | val publisher: String?, 15 | val alternativePublishers: Set, 16 | val ageRating: Int?, 17 | val language: String?, 18 | val genres: Collection, 19 | val tags: Collection, 20 | val totalBookCount: Int?, 21 | val authors: Collection, 22 | val releaseYear: Int?, 23 | val links: Collection, 24 | 25 | val statusLock: Boolean, 26 | val titleLock: Boolean, 27 | val titleSortLock: Boolean, 28 | val alternativeTitlesLock: Boolean, 29 | val summaryLock: Boolean, 30 | val readingDirectionLock: Boolean, 31 | val publisherLock: Boolean, 32 | val ageRatingLock: Boolean, 33 | val languageLock: Boolean, 34 | val genresLock: Boolean, 35 | val tagsLock: Boolean, 36 | val totalBookCountLock: Boolean, 37 | val authorsLock: Boolean, 38 | val releaseYearLock: Boolean, 39 | val linksLock: Boolean, 40 | ) 41 | -------------------------------------------------------------------------------- /komf-mediaserver/src/commonMain/kotlin/snd/komf/mediaserver/model/MediaServerSeriesMetadataUpdate.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.mediaserver.model 2 | 3 | import snd.komf.model.ReadingDirection 4 | import snd.komf.model.SeriesStatus 5 | import snd.komf.model.SeriesTitle 6 | import snd.komf.model.WebLink 7 | 8 | data class MediaServerSeriesMetadataUpdate( 9 | val status: SeriesStatus? = null, 10 | val title: SeriesTitle? = null, 11 | val alternativeTitles: Collection? = null, 12 | val titleSort: SeriesTitle? = null, 13 | val summary: String? = null, 14 | val readingDirection: ReadingDirection? = null, 15 | val publisher: String? = null, 16 | val alternativePublishers: Collection? = null, 17 | val ageRating: Int? = null, 18 | val language: String? = null, 19 | val genres: List? = null, 20 | val tags: List? = null, 21 | val totalBookCount: Int? = null, 22 | val authors: Collection? = null, 23 | val releaseYear: Int? = null, 24 | val links: Collection? = null, 25 | 26 | val statusLock: Boolean? = null, 27 | val titleLock: Boolean? = null, 28 | val titleSortLock: Boolean? = null, 29 | val alternativeTitlesLock: Boolean? = null, 30 | val summaryLock: Boolean? = null, 31 | val readingDirectionLock: Boolean? = null, 32 | val publisherLock: Boolean? = null, 33 | val ageRatingLock: Boolean? = null, 34 | val languageLock: Boolean? = null, 35 | val genresLock: Boolean? = null, 36 | val tagsLock: Boolean? = null, 37 | val totalBookCountLock: Boolean? = null, 38 | val authorsLock: Boolean? = null, 39 | val releaseYearLock: Boolean? = null, 40 | val linksLock: Boolean? = null 41 | ) 42 | -------------------------------------------------------------------------------- /komf-mediaserver/src/commonMain/kotlin/snd/komf/mediaserver/model/MediaServerSeriesSearch.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.mediaserver.model 2 | 3 | data class MediaServerSeriesSearch( 4 | val id: MediaServerSeriesId, 5 | val libraryId: MediaServerLibraryId, 6 | val name: String, 7 | ) -------------------------------------------------------------------------------- /komf-mediaserver/src/commonMain/kotlin/snd/komf/mediaserver/model/MediaServerSeriesThumbnail.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.mediaserver.model 2 | 3 | data class MediaServerSeriesThumbnail( 4 | val id: MediaServerThumbnailId, 5 | val seriesId: MediaServerSeriesId, 6 | val type: String?, 7 | val selected: Boolean, 8 | ) 9 | @JvmInline 10 | value class MediaServerThumbnailId(val value: String){ 11 | override fun toString() = value 12 | } 13 | -------------------------------------------------------------------------------- /komf-mediaserver/src/commonMain/kotlin/snd/komf/mediaserver/model/Page.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.mediaserver.model 2 | 3 | data class Page ( 4 | val content: List, 5 | val pageNumber: Int, 6 | val totalPages: Int?, 7 | val totalElements: Int?, 8 | ) -------------------------------------------------------------------------------- /komf-mediaserver/src/commonMain/kotlin/snd/komf/mediaserver/model/SeriesAndBookMetadata.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.mediaserver.model 2 | 3 | import snd.komf.model.BookMetadata 4 | import snd.komf.model.SeriesMetadata 5 | 6 | data class SeriesAndBookMetadata( 7 | val seriesMetadata: SeriesMetadata, 8 | val bookMetadata: Map, 9 | ) 10 | -------------------------------------------------------------------------------- /komf-mediaserver/src/commonMain/sqldelight/migrations/1.sqm: -------------------------------------------------------------------------------- 1 | CREATE TABLE BookThumbnail ( 2 | bookId TEXT NOT NULL PRIMARY KEY, 3 | seriesId TEXT NOT NULL, 4 | thumbnailId TEXT, 5 | mediaServer TEXT NOT NULL 6 | ); 7 | CREATE INDEX book_thumbnails_series_idx ON BookThumbnail (seriesId); 8 | CREATE INDEX book_thumbnails_server_type_idx ON BookThumbnail (mediaServer); 9 | 10 | CREATE TABLE SeriesThumbnail ( 11 | seriesId TEXT NOT NULL PRIMARY KEY, 12 | thumbnailId TEXT, 13 | mediaServer TEXT NOT NULL 14 | ); 15 | CREATE INDEX series_thumbnail_server_type_idx ON SeriesThumbnail (mediaServer); 16 | 17 | CREATE TABLE SeriesMatch ( 18 | seriesId TEXT NOT NULL, 19 | type TEXT NOT NULL, 20 | mediaServer TEXT NOT NULL, 21 | provider TEXT NOT NULL, 22 | providerSeriesId TEXT NOT NULL, 23 | PRIMARY KEY (seriesId, mediaServer) 24 | ); 25 | CREATE INDEX series_match_type_idx ON SeriesMatch (type); 26 | 27 | 28 | CREATE TABLE KomfJobRecord ( 29 | id TEXT NOT NULL, 30 | seriesId TEXT NOT NULL, 31 | status TEXT NOT NULL, 32 | message TEXT, 33 | startedAt INTEGER NOT NULL, 34 | finishedAt INTEGER, 35 | PRIMARY KEY (id) 36 | ); 37 | CREATE INDEX komf_job_series_id_idx ON KomfJobRecord(seriesId); 38 | CREATE INDEX komf_job_status_idx ON KomfJobRecord(status); 39 | CREATE INDEX komf_job_started_at_idx ON KomfJobRecord(startedAt); 40 | CREATE INDEX komf_job_finished_at_idx ON KomfJobRecord(finishedAt); 41 | 42 | -- MIGRATION FROM OLD TABLES 43 | CREATE TABLE IF NOT EXISTS BOOK_THUMBNAILS( 44 | BOOK_ID TEXT NOT NULL PRIMARY KEY, 45 | SERIES_ID TEXT NOT NULL, 46 | THUMBNAIL_ID TEXT, 47 | SERVER_TYPE TEXT NOT NULL 48 | ); 49 | CREATE TABLE IF NOT EXISTS SERIES_THUMBNAILS( 50 | SERIES_ID TEXT NOT NULL PRIMARY KEY, 51 | THUMBNAIL_ID TEXT, 52 | SERVER_TYPE TEXT NOT NULL 53 | ); 54 | CREATE TABLE IF NOT EXISTS SERIES_MATCH( 55 | SERIES_ID TEXT NOT NULL, 56 | TYPE TEXT NOT NULL, 57 | SERVER_TYPE TEXT NOT NULL, 58 | PROVIDER TEXT NOT NULL, 59 | PROVIDER_SERIES_ID TEXT NOT NULL, 60 | PRIMARY KEY (SERIES_ID, SERVER_TYPE) 61 | ); 62 | 63 | INSERT INTO BookThumbnail (bookId, seriesId, thumbnailId, mediaServer) 64 | SELECT BOOK_ID, SERIES_ID, THUMBNAIL_ID, SERVER_TYPE 65 | FROM BOOK_THUMBNAILS; 66 | 67 | INSERT INTO SeriesThumbnail (seriesId, thumbnailId, mediaServer) 68 | SELECT SERIES_ID, THUMBNAIL_ID, SERVER_TYPE 69 | FROM SERIES_THUMBNAILS; 70 | 71 | INSERT INTO SeriesMatch (seriesId, type, mediaServer, provider, providerSeriesId) 72 | SELECT SERIES_ID, TYPE, SERVER_TYPE, PROVIDER, PROVIDER_SERIES_ID 73 | FROM SERIES_MATCH; 74 | 75 | DROP TABLE BOOK_THUMBNAILS; 76 | DROP TABLE SERIES_THUMBNAILS; 77 | DROP TABLE SERIES_MATCH; 78 | DROP TABLE IF EXISTS flyway_schema_history; 79 | -------------------------------------------------------------------------------- /komf-mediaserver/src/commonMain/sqldelight/snd/komf/mediaserver/repository/BookThumbnail.sq: -------------------------------------------------------------------------------- 1 | import snd.komf.mediaserver.model.MediaServer; 2 | import snd.komf.mediaserver.model.MediaServerBookId; 3 | import snd.komf.mediaserver.model.MediaServerSeriesId; 4 | import snd.komf.mediaserver.model.MediaServerThumbnailId; 5 | 6 | CREATE TABLE BookThumbnail ( 7 | bookId TEXT AS MediaServerBookId NOT NULL PRIMARY KEY, 8 | seriesId TEXT AS MediaServerSeriesId NOT NULL, 9 | thumbnailId TEXT AS MediaServerThumbnailId, 10 | mediaServer TEXT AS MediaServer NOT NULL 11 | ); 12 | CREATE INDEX book_thumbnails_series_idx ON BookThumbnail (seriesId); 13 | CREATE INDEX book_thumbnails_server_type_idx ON BookThumbnail (mediaServer); 14 | 15 | 16 | findFor: 17 | SELECT * FROM BookThumbnail WHERE bookId=?; 18 | 19 | save: 20 | INSERT OR REPLACE INTO BookThumbnail (bookId, seriesId, thumbnailId, mediaServer) 21 | VALUES(?,?,?,?) ; 22 | 23 | delete: 24 | DELETE FROM BookThumbnail WHERE bookId=?; -------------------------------------------------------------------------------- /komf-mediaserver/src/commonMain/sqldelight/snd/komf/mediaserver/repository/KomfJobRecord.sq: -------------------------------------------------------------------------------- 1 | import kotlinx.datetime.Instant; 2 | import snd.komf.mediaserver.jobs.MetadataJobId; 3 | import snd.komf.mediaserver.jobs.MetadataJobStatus; 4 | import snd.komf.mediaserver.model.MediaServerSeriesId; 5 | 6 | CREATE TABLE KomfJobRecord ( 7 | id TEXT AS MetadataJobId NOT NULL, 8 | seriesId TEXT AS MediaServerSeriesId NOT NULL, 9 | status TEXT AS MetadataJobStatus NOT NULL, 10 | message TEXT, 11 | startedAt INTEGER AS Instant NOT NULL, 12 | finishedAt INTEGER AS Instant , 13 | PRIMARY KEY (id) 14 | ); 15 | CREATE INDEX komf_job_series_id_idx ON KomfJobRecord(seriesId); 16 | CREATE INDEX komf_job_status_idx ON KomfJobRecord(status); 17 | CREATE INDEX komf_job_started_at_idx ON KomfJobRecord(startedAt); 18 | CREATE INDEX komf_job_finished_at_idx ON KomfJobRecord(finishedAt); 19 | 20 | get: 21 | SELECT * FROM KomfJobRecord WHERE id=?; 22 | 23 | countAll: 24 | SELECT COUNT() FROM KomfJobRecord; 25 | findAll: 26 | SELECT * FROM KomfJobRecord ORDER BY startedAt DESC LIMIT ? OFFSET ?; 27 | 28 | countAllWithStatus: 29 | SELECT COUNT() FROM KomfJobRecord WHERE status=?; 30 | findAllWithStatus: 31 | SELECT * FROM KomfJobRecord WHERE status=? ORDER BY startedAt DESC LIMIT ? OFFSET ?; 32 | 33 | save: 34 | INSERT OR REPLACE INTO KomfJobRecord VALUES ?; 35 | 36 | deleteAll: 37 | DELETE FROM KomfJobRecord; 38 | 39 | cancellAllRunning: 40 | UPDATE KomfJobRecord SET status = 'FAILED', message = 'Cancelled' WHERE status = 'RUNNING'; 41 | 42 | deleteAllBeforeDate: 43 | DELETE FROM KomfJobRecord WHERE startedAt <= ?; 44 | -------------------------------------------------------------------------------- /komf-mediaserver/src/commonMain/sqldelight/snd/komf/mediaserver/repository/SeriesMatch.sq: -------------------------------------------------------------------------------- 1 | import snd.komf.mediaserver.model.MediaServer; 2 | import snd.komf.mediaserver.model.MediaServerSeriesId; 3 | import snd.komf.model.MatchType; 4 | import snd.komf.model.ProviderSeriesId; 5 | import snd.komf.providers.CoreProviders; 6 | 7 | CREATE TABLE SeriesMatch ( 8 | seriesId TEXT AS MediaServerSeriesId NOT NULL, 9 | type TEXT AS MatchType NOT NULL, 10 | mediaServer TEXT AS MediaServer NOT NULL, 11 | provider TEXT AS CoreProviders NOT NULL, 12 | providerSeriesId TEXT AS ProviderSeriesId NOT NULL, 13 | PRIMARY KEY (seriesId, mediaServer) 14 | ); 15 | CREATE INDEX series_match_type_idx ON SeriesMatch (type); 16 | 17 | findManualFor: 18 | SELECT * FROM SeriesMatch WHERE type='MANUAL' AND seriesId=? AND mediaServer=?; 19 | 20 | save: 21 | INSERT OR REPLACE INTO SeriesMatch (seriesId,type,mediaServer,provider,providerSeriesId) 22 | VALUES (?,?,?,?,?); 23 | 24 | delete: 25 | DELETE FROM SeriesMatch WHERE seriesId=?; -------------------------------------------------------------------------------- /komf-mediaserver/src/commonMain/sqldelight/snd/komf/mediaserver/repository/SeriesThumbnail.sq: -------------------------------------------------------------------------------- 1 | import snd.komf.mediaserver.model.MediaServer; 2 | import snd.komf.mediaserver.model.MediaServerSeriesId; 3 | import snd.komf.mediaserver.model.MediaServerThumbnailId; 4 | 5 | CREATE TABLE SeriesThumbnail ( 6 | seriesId TEXT AS MediaServerSeriesId NOT NULL PRIMARY KEY, 7 | thumbnailId TEXT AS MediaServerThumbnailId, 8 | mediaServer TEXT AS MediaServer NOT NULL 9 | ); 10 | CREATE INDEX series_thumbnail_server_type_idx ON SeriesThumbnail (mediaServer); 11 | 12 | 13 | findFor: 14 | SELECT * FROM SeriesThumbnail WHERE seriesId=?; 15 | 16 | save: 17 | INSERT OR REPLACE INTO SeriesThumbnail (seriesId,thumbnailId,mediaServer) 18 | VALUES(?,?,?); 19 | 20 | delete: 21 | DELETE FROM SeriesThumbnail WHERE seriesId=?; -------------------------------------------------------------------------------- /komf-mediaserver/src/jvmMain/kotlin/snd/komf/mediaserver/kavita/JvmJwtConsumer.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.mediaserver.kavita 2 | 3 | import kotlinx.datetime.Instant 4 | import org.jose4j.jwt.consumer.JwtConsumerBuilder 5 | 6 | class JvmJwtConsumer : JwtConsumer { 7 | private val jwtConsumer = JwtConsumerBuilder().apply { 8 | setSkipSignatureVerification() 9 | setAllowedClockSkewInSeconds(600) // 10 minutes 10 | }.build() 11 | 12 | override fun processToExpirationDateClaim(jwt: String): Instant { 13 | val claims = jwtConsumer.processToClaims(jwt) 14 | return Instant.fromEpochMilliseconds(claims.expirationTime.valueInMillis) 15 | } 16 | } -------------------------------------------------------------------------------- /komf-mediaserver/src/jvmMain/kotlin/snd/komf/mediaserver/repository/Database.jvm.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.mediaserver.repository 2 | 3 | import app.cash.sqldelight.db.SqlDriver 4 | import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver 5 | import java.nio.file.Path 6 | 7 | actual class DriverFactory(private val databaseFile: Path) { 8 | actual fun createDriver(): SqlDriver { 9 | val driver: SqlDriver = 10 | JdbcSqliteDriver( 11 | url = "jdbc:sqlite:${databaseFile}", 12 | schema = Database.Schema, 13 | migrateEmptySchema = true 14 | ) 15 | return driver 16 | } 17 | } -------------------------------------------------------------------------------- /komf-notifications/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi 2 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget 3 | 4 | plugins { 5 | alias(libs.plugins.androidLibrary) 6 | alias(libs.plugins.kotlinAtomicfu) 7 | alias(libs.plugins.kotlinMultiplatform) 8 | alias(libs.plugins.kotlinSerialization) 9 | } 10 | 11 | group = "io.github.snd-r" 12 | version = libs.versions.app.version.get() 13 | 14 | kotlin { 15 | jvmToolchain(17) 16 | jvm { 17 | @OptIn(ExperimentalKotlinGradlePluginApi::class) 18 | compilerOptions { 19 | jvmTarget.set(JvmTarget.JVM_17) 20 | } 21 | } 22 | androidTarget { 23 | @OptIn(ExperimentalKotlinGradlePluginApi::class) 24 | compilerOptions { 25 | jvmTarget.set(JvmTarget.JVM_17) 26 | } 27 | } 28 | 29 | sourceSets { 30 | commonMain.dependencies { 31 | implementation(project(":komf-core")) 32 | implementation(project(":komf-mediaserver")) 33 | implementation(libs.kotlin.logging) 34 | implementation(libs.kotlinx.coroutines.core) 35 | implementation(libs.kotlinx.datetime) 36 | implementation(libs.kotlinx.io.core) 37 | implementation(libs.kotlinx.serialization.json) 38 | implementation(libs.ktor.client.core) 39 | implementation(libs.ktor.client.content.negotiation) 40 | implementation(libs.ktor.client.encoding) 41 | implementation(libs.ktor.serialization.kotlinx.json) 42 | 43 | api(libs.velocity.core) 44 | api(libs.tika.core) 45 | } 46 | } 47 | } 48 | 49 | android { 50 | namespace = "snd.komf" 51 | compileSdk = 35 52 | 53 | defaultConfig { 54 | minSdk = 26 55 | } 56 | compileOptions { 57 | sourceCompatibility = JavaVersion.VERSION_17 58 | targetCompatibility = JavaVersion.VERSION_17 59 | } 60 | 61 | } -------------------------------------------------------------------------------- /komf-notifications/src/commonMain/kotlin/snd/komf/notifications/VelocityTemplates.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.notifications 2 | 3 | import org.apache.velocity.Template 4 | import org.apache.velocity.VelocityContext 5 | import org.apache.velocity.runtime.RuntimeInstance 6 | import snd.komf.notifications.discord.model.NotificationContext 7 | import java.io.StringReader 8 | import java.io.StringWriter 9 | 10 | internal object VelocityTemplates { 11 | 12 | fun NotificationContext.toVelocityContext(): VelocityContext { 13 | val context = VelocityContext() 14 | context.put("library", library) 15 | context.put("series", series) 16 | context.put("books", books.sortedBy { it.name }) 17 | context.put("mediaServer", mediaServer) 18 | return context 19 | } 20 | 21 | fun RuntimeInstance.loadTemplateByName(name: String): Template? { 22 | if (getLoaderNameForResource(name) == null) return null 23 | return runCatching { getTemplate(name) }.getOrNull() 24 | } 25 | 26 | fun renderTemplate(template: Template, context: VelocityContext): String { 27 | return StringWriter().use { 28 | template.merge(context, it) 29 | it.toString() 30 | } 31 | } 32 | 33 | fun RuntimeInstance.templateFromString(template: String): Template { 34 | val runtimeService = this 35 | return Template().apply { 36 | setRuntimeServices(runtimeService) 37 | data = runtimeService.parse(StringReader(template), this) 38 | initDocument() 39 | } 40 | } 41 | } -------------------------------------------------------------------------------- /komf-notifications/src/commonMain/kotlin/snd/komf/notifications/apprise/AppriseConfig.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.notifications.apprise 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class AppriseConfig( 7 | val urls: List? = null, 8 | val seriesCover: Boolean = false, 9 | ) -------------------------------------------------------------------------------- /komf-notifications/src/commonMain/kotlin/snd/komf/notifications/discord/DiscordConfig.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.notifications.discord 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class DiscordConfig( 7 | val webhooks: List? = null, 8 | val embedColor: String = "1F8B4C", 9 | val seriesCover: Boolean = false, 10 | ) -------------------------------------------------------------------------------- /komf-notifications/src/commonMain/kotlin/snd/komf/notifications/discord/model/NotificationContext.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.notifications.discord.model 2 | 3 | import snd.komf.model.Image 4 | 5 | data class NotificationContext( 6 | val library: LibraryContext, 7 | val series: SeriesContext, 8 | val books: List, 9 | val mediaServer: String, 10 | val seriesCover: Image?, 11 | ) 12 | 13 | data class LibraryContext( 14 | val id: String, 15 | val name: String 16 | ) 17 | 18 | data class SeriesContext( 19 | val id: String, 20 | val name: String, 21 | val bookCount: Int, 22 | val metadata: SeriesMetadataContext 23 | ) 24 | 25 | data class SeriesMetadataContext( 26 | val status: String, 27 | val title: String, 28 | val titleSort: String, 29 | val alternativeTitles: List, 30 | val summary: String, 31 | val readingDirection: String?, 32 | val publisher: String?, 33 | val alternativePublishers: Set, 34 | val ageRating: Int?, 35 | val language: String?, 36 | val genres: List, 37 | val tags: List, 38 | val totalBookCount: Int?, 39 | val authors: List, 40 | val releaseYear: Int?, 41 | val links: List, 42 | ) 43 | 44 | data class BookContext( 45 | val id: String, 46 | val name: String, 47 | val number: Int, 48 | val metadata: BookMetadataContext 49 | ) 50 | 51 | data class BookMetadataContext( 52 | val title: String, 53 | val summary: String?, 54 | val number: String, 55 | val numberSort: String?, 56 | val releaseDate: String?, 57 | val authors: List, 58 | val tags: List, 59 | val isbn: String?, 60 | val links: List, 61 | ) 62 | 63 | data class AlternativeTitleContext( 64 | val label: String, 65 | val title: String, 66 | ) 67 | 68 | data class AuthorContext( 69 | val name: String, 70 | val role: String, 71 | ) 72 | 73 | data class WebLinkContext( 74 | val label: String, 75 | val url: String, 76 | ) 77 | -------------------------------------------------------------------------------- /komf-notifications/src/commonMain/kotlin/snd/komf/notifications/discord/model/Webhook.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.notifications.discord.model 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | 7 | @Serializable 8 | data class Webhook( 9 | val type: Int, 10 | val id: String, 11 | val name: String, 12 | val avatar: String?, 13 | @SerialName("channel_id") 14 | val channelId: String, 15 | @SerialName("guild_id") 16 | val guildId: String, 17 | @SerialName("application_id") 18 | val applicationId: String?, 19 | val token: String, 20 | ) 21 | -------------------------------------------------------------------------------- /komf-notifications/src/commonMain/kotlin/snd/komf/notifications/discord/model/WebhookExecuteRequest.kt: -------------------------------------------------------------------------------- 1 | package snd.komf.notifications.discord.model 2 | 3 | import kotlinx.datetime.LocalDate 4 | import kotlinx.serialization.SerialName 5 | import kotlinx.serialization.Serializable 6 | 7 | @Serializable 8 | data class WebhookExecuteRequest( 9 | val content: String? = null, 10 | val username: String? = null, 11 | @SerialName("avatar_url") 12 | val avatarUrl: String? = null, 13 | val tts: Boolean? = null, 14 | val embeds: Collection? = null, 15 | val flags: Int? = null, 16 | ) 17 | 18 | @Serializable 19 | data class Embed( 20 | val title: String? = null, 21 | val type: String? = null, 22 | val description: String? = null, 23 | val url: String? = null, 24 | val timestamp: LocalDate? = null, 25 | val color: Int? = null, 26 | val footer: EmbedFooter? = null, 27 | val image: EmbedImage? = null, 28 | val thumbnail: EmbedThumbnail? = null, 29 | val provider: EmbedProvider? = null, 30 | val author: EmbedAuthor? = null, 31 | val fields: Collection? = null, 32 | ) 33 | 34 | @Serializable 35 | data class EmbedFooter( 36 | val text: String, 37 | @SerialName("icon_url") 38 | val iconUrl: String? = null, 39 | @SerialName("proxy_icon_url") 40 | val proxyIconUrl: String? = null, 41 | ) 42 | 43 | @Serializable 44 | data class EmbedImage( 45 | val url: String, 46 | @SerialName("proxy_url") 47 | val proxyUrl: String? = null, 48 | val height: Int? = null, 49 | val width: Int? = null 50 | ) 51 | 52 | @Serializable 53 | data class EmbedThumbnail( 54 | val url: String, 55 | @SerialName("proxy_url") 56 | val proxyUrl: String? = null, 57 | val height: Int? = null, 58 | val width: Int? = null 59 | ) 60 | 61 | @Serializable 62 | data class EmbedProvider( 63 | val name: String? = null, 64 | val url: String? = null, 65 | ) 66 | 67 | @Serializable 68 | data class EmbedAuthor( 69 | val name: String, 70 | val url: String? = null, 71 | @SerialName("icon_url") 72 | val iconUrl: String? = null, 73 | @SerialName("proxy_icon_url") 74 | val proxyIconUrl: String? = null, 75 | ) 76 | 77 | @Serializable 78 | data class EmbedField( 79 | val name: String, 80 | val value: String, 81 | val inline: Boolean, 82 | ) 83 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "komf" 2 | 3 | pluginManagement { 4 | repositories { 5 | google() 6 | gradlePluginPortal() 7 | mavenCentral() 8 | } 9 | } 10 | @Suppress("UnstableApiUsage") 11 | dependencyResolutionManagement { 12 | repositories { 13 | google() 14 | mavenCentral() 15 | maven("https://maven.pkg.jetbrains.space/public/p/ktor/eap") 16 | maven("https://oss.sonatype.org/content/repositories/snapshots/") 17 | } 18 | } 19 | 20 | include(":komf-app") 21 | include(":komf-core") 22 | include(":komf-mediaserver") 23 | include(":komf-notifications") 24 | include(":komf-client") 25 | include(":komf-api-models") 26 | --------------------------------------------------------------------------------